mirror of
https://github.com/wassname/talk.git
synced 2026-07-02 11:55:34 +08:00
467 lines
14 KiB
TypeScript
467 lines
14 KiB
TypeScript
import cookie from "cookie";
|
|
import { IncomingMessage } from "http";
|
|
import { Redis } from "ioredis";
|
|
import Joi from "joi";
|
|
import jwt, { KeyFunction, SignOptions, VerifyOptions } from "jsonwebtoken";
|
|
import { DateTime } from "luxon";
|
|
import { Bearer, BearerOptions } from "permit";
|
|
import uuid from "uuid/v4";
|
|
|
|
import { DEFAULT_SESSION_LENGTH } from "coral-common/constants";
|
|
import { Omit } from "coral-common/types";
|
|
import { Config } from "coral-server/config";
|
|
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";
|
|
|
|
/**
|
|
* The following Header Parameter names for use in JWSs are registered
|
|
* in the IANA "JSON Web Signature and Encryption Header Parameters"
|
|
* registry established by Section 9.1, with meanings as defined in the
|
|
* subsections below.
|
|
*
|
|
* As indicated by the common registry, JWSs and JWEs share a common
|
|
* Header Parameter space; when a parameter is used by both
|
|
* specifications, its usage must be compatible between the
|
|
* specifications.
|
|
*
|
|
* https://tools.ietf.org/html/rfc7515#section-4.1
|
|
*/
|
|
export interface StandardHeader {
|
|
/**
|
|
* The "kid" (key ID) Header Parameter is a hint indicating which key
|
|
* was used to secure the JWS. This parameter allows originators to
|
|
* explicitly signal a change of key to recipients. The structure of
|
|
* the "kid" value is unspecified. Its value MUST be a case-sensitive
|
|
* string. Use of this Header Parameter is OPTIONAL.
|
|
*
|
|
* When used with a JWK, the "kid" value is used to match a JWK "kid"
|
|
* parameter value.
|
|
*
|
|
* https://tools.ietf.org/html/rfc7515#section-4.1.4
|
|
*/
|
|
kid?: string;
|
|
}
|
|
|
|
/**
|
|
* The following Claim Names are registered in the IANA "JSON Web Token
|
|
* Claims" registry established by Section 10.1. None of the claims
|
|
* defined below are intended to be mandatory to use or implement in all
|
|
* cases, but rather they provide a starting point for a set of useful,
|
|
* interoperable claims. Applications using JWTs should define which
|
|
* specific claims they use and when they are required or optional. All
|
|
* the names are short because a core goal of JWTs is for the
|
|
* representation to be compact.
|
|
*
|
|
* https://tools.ietf.org/html/rfc7519#section-4.1
|
|
*/
|
|
export interface StandardClaims {
|
|
/**
|
|
* The "jti" (JWT ID) claim provides a unique identifier for the JWT. The
|
|
* identifier value MUST be assigned in a manner that ensures that there is a
|
|
* negligible probability that the same value will be accidentally assigned to
|
|
* a different data object; if the application uses multiple issuers,
|
|
* collisions MUST be prevented among values produced by different issuers as
|
|
* well. The "jti" claim can be used to prevent the JWT from being replayed.
|
|
* The "jti" value is a case- sensitive string. Use of this claim is
|
|
* OPTIONAL.
|
|
*
|
|
* https://tools.ietf.org/html/rfc7519#section-4.1.7
|
|
*/
|
|
jti?: string;
|
|
|
|
/**
|
|
* The "aud" (audience) claim identifies the recipients that the JWT is
|
|
* intended for. Each principal intended to process the JWT MUST
|
|
* identify itself with a value in the audience claim. If the principal
|
|
* processing the claim does not identify itself with a value in the
|
|
* "aud" claim when this claim is present, then the JWT MUST be
|
|
* rejected. In the general case, the "aud" value is an array of case-
|
|
* sensitive strings, each containing a StringOrURI value. In the
|
|
* special case when the JWT has one audience, the "aud" value MAY be a
|
|
* single case-sensitive string containing a StringOrURI value. The
|
|
* interpretation of audience values is generally application specific.
|
|
* Use of this claim is OPTIONAL.
|
|
*
|
|
* https://tools.ietf.org/html/rfc7519#section-4.1.3
|
|
*/
|
|
aud?: string;
|
|
|
|
/**
|
|
* The "sub" (subject) claim identifies the principal that is the
|
|
* subject of the JWT. The claims in a JWT are normally statements
|
|
* about the subject. The subject value MUST either be scoped to be
|
|
* locally unique in the context of the issuer or be globally unique.
|
|
* The processing of this claim is generally application specific. The
|
|
* "sub" value is a case-sensitive string containing a StringOrURI
|
|
* value. Use of this claim is OPTIONAL.
|
|
*
|
|
* https://tools.ietf.org/html/rfc7519#section-4.1.2
|
|
*/
|
|
sub?: string;
|
|
|
|
/**
|
|
* The "iss" (issuer) claim identifies the principal that issued the
|
|
* JWT. The processing of this claim is generally application specific.
|
|
* The "iss" value is a case-sensitive string containing a StringOrURI
|
|
* value. Use of this claim is OPTIONAL.
|
|
*
|
|
* https://tools.ietf.org/html/rfc7519#section-4.1.2
|
|
*/
|
|
iss?: string;
|
|
|
|
/**
|
|
* The "exp" (expiration time) claim identifies the expiration time on
|
|
* or after which the JWT MUST NOT be accepted for processing. The
|
|
* processing of the "exp" claim requires that the current date/time
|
|
* MUST be before the expiration date/time listed in the "exp" claim.
|
|
* Implementers MAY provide for some small leeway, usually no more than
|
|
* a few minutes, to account for clock skew. Its value MUST be a number
|
|
* containing a NumericDate value. Use of this claim is OPTIONAL.
|
|
*
|
|
* https://tools.ietf.org/html/rfc7519#section-4.1.4
|
|
*/
|
|
exp?: number;
|
|
|
|
/**
|
|
* The "nbf" (not before) claim identifies the time before which the JWT
|
|
* MUST NOT be accepted for processing. The processing of the "nbf"
|
|
* claim requires that the current date/time MUST be after or equal to
|
|
* the not-before date/time listed in the "nbf" claim. Implementers MAY
|
|
* provide for some small leeway, usually no more than a few minutes, to
|
|
* account for clock skew. Its value MUST be a number containing a
|
|
* NumericDate value. Use of this claim is OPTIONAL.
|
|
*
|
|
* https://tools.ietf.org/html/rfc7519#section-4.1.5
|
|
*/
|
|
nbf?: number;
|
|
|
|
/**
|
|
* The "iat" (issued at) claim identifies the time at which the JWT was
|
|
* issued. This claim can be used to determine the age of the JWT. Its
|
|
* value MUST be a number containing a NumericDate value. Use of this
|
|
* claim is OPTIONAL.
|
|
*
|
|
* https://tools.ietf.org/html/rfc7519#section-4.1.6
|
|
*/
|
|
iat?: number;
|
|
}
|
|
|
|
export const StandardClaimsSchema = Joi.object().keys({
|
|
jti: Joi.string(),
|
|
aud: Joi.string(),
|
|
sub: Joi.string(),
|
|
iss: Joi.string(),
|
|
exp: Joi.number(),
|
|
nbf: Joi.number(),
|
|
iat: Joi.number(),
|
|
});
|
|
|
|
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 | string;
|
|
algorithm: JWTSigningAlgorithm;
|
|
}
|
|
|
|
export interface JWTVerifyingConfig {
|
|
secret: Buffer | string | KeyFunction;
|
|
algorithm: JWTSigningAlgorithm;
|
|
}
|
|
|
|
export function dateToSeconds(date: Date): number {
|
|
return Math.round(DateTime.fromJSDate(date).toSeconds());
|
|
}
|
|
|
|
export function createAsymmetricSigningConfig(
|
|
algorithm: AsymmetricSigningAlgorithm,
|
|
secret: string
|
|
): JWTSigningConfig {
|
|
return {
|
|
// Secrets have their newlines encoded with newline literals.
|
|
secret: Buffer.from(secret.replace(/\\n/g, "\n"), "utf8"),
|
|
algorithm,
|
|
};
|
|
}
|
|
|
|
export function createSymmetricSigningConfig(
|
|
algorithm: SymmetricSigningAlgorithm,
|
|
secret: string
|
|
): JWTSigningConfig {
|
|
return {
|
|
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);
|
|
}
|
|
|
|
throw new AuthenticationError(`invalid algorithm=${algorithm} specified`);
|
|
}
|
|
|
|
export type SigningTokenOptions = Pick<
|
|
SignOptions,
|
|
"jwtid" | "audience" | "issuer" | "expiresIn" | "notBefore"
|
|
>;
|
|
|
|
export const signTokenString = async (
|
|
{ algorithm, secret }: JWTSigningConfig,
|
|
user: Pick<User, "id">,
|
|
tenant: Pick<Tenant, "id">,
|
|
options: SigningTokenOptions = {},
|
|
now = new Date()
|
|
) =>
|
|
jwt.sign(
|
|
{
|
|
iat: dateToSeconds(now),
|
|
},
|
|
secret,
|
|
{
|
|
jwtid: uuid(),
|
|
expiresIn: DEFAULT_SESSION_LENGTH,
|
|
...options,
|
|
issuer: tenant.id,
|
|
subject: user.id,
|
|
algorithm,
|
|
}
|
|
);
|
|
|
|
export const signPATString = async (
|
|
{ algorithm, secret }: JWTSigningConfig,
|
|
user: User,
|
|
options: SigningTokenOptions,
|
|
now = new Date()
|
|
) =>
|
|
jwt.sign({ pat: true, iat: dateToSeconds(now) }, secret, {
|
|
...options,
|
|
subject: user.id,
|
|
algorithm,
|
|
});
|
|
|
|
export async function signString<T extends {}>(
|
|
{ algorithm, secret }: JWTSigningConfig,
|
|
payload: T,
|
|
options: Omit<SignOptions, "algorithm"> = {}
|
|
) {
|
|
return jwt.sign(payload, secret, { ...options, algorithm });
|
|
}
|
|
|
|
/**
|
|
* 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 = false
|
|
) {
|
|
const options: BearerOptions = {
|
|
basic: "password",
|
|
};
|
|
|
|
if (!excludeQuery) {
|
|
options.query = "accessToken";
|
|
}
|
|
|
|
const permit = new Bearer(options);
|
|
|
|
return permit.check(req) || null;
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
export function extractTokenFromRequest(
|
|
req: Request | IncomingMessage,
|
|
excludeQuery = false
|
|
): string | null {
|
|
return (
|
|
extractJWTFromRequestHeaders(req, excludeQuery) ||
|
|
extractJWTFromRequestCookie(req)
|
|
);
|
|
}
|
|
|
|
function generateJTIRevokedKey(jti: string) {
|
|
// jtir: JTI Revoked namespace.
|
|
return `jtir:${jti}`;
|
|
}
|
|
|
|
/**
|
|
* 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 exp time that the token expired at
|
|
* @param now the current date
|
|
*/
|
|
export async function revokeJWT(
|
|
redis: Redis,
|
|
jti: string,
|
|
exp: number,
|
|
now = new Date()
|
|
) {
|
|
const validFor = Math.round(
|
|
DateTime.fromSeconds(exp).diff(DateTime.fromJSDate(now), "seconds").seconds
|
|
);
|
|
|
|
if (validFor > 0) {
|
|
await redis.setex(
|
|
generateJTIRevokedKey(jti),
|
|
validFor,
|
|
Math.round(DateTime.fromJSDate(now).toSeconds())
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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) {
|
|
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);
|
|
}
|
|
}
|
|
|
|
export function verifyJWT(
|
|
tokenString: string,
|
|
{ algorithm, secret }: JWTVerifyingConfig,
|
|
now: Date,
|
|
options: Omit<VerifyOptions, "algorithms" | "clockTimestamp"> = {}
|
|
) {
|
|
try {
|
|
return jwt.verify(tokenString, secret, {
|
|
...options,
|
|
algorithms: [algorithm],
|
|
clockTimestamp: Math.floor(now.getTime() / 1000),
|
|
}) as object;
|
|
} catch (err) {
|
|
throw new TokenInvalidError(tokenString, "token validation error", err);
|
|
}
|
|
}
|
|
|
|
export function decodeJWT(tokenString: string) {
|
|
try {
|
|
return jwt.decode(tokenString, {}) as StandardClaims;
|
|
} catch (err) {
|
|
throw new TokenInvalidError(tokenString, "token validation error", err);
|
|
}
|
|
}
|