import * as jsonata from "jsonata"; import Vars from "../helpers/Vars"; import { IChassisContext, IJWT } from "../index"; import * as _ from "lodash"; import assert = require("assert"); import { Utils } from "."; import * as jwt from "jsonwebtoken"; import JWTPlugin from "../plugins/jwt"; const BEARER = "bearer "; export default class JWT { public static getHeaders(token: string, headers?: any): any { headers = headers || {}; headers.Authorization = "Bearer " + token; return headers; } public static getBearerToken(context: IChassisContext, req: any) { return this.getBearerTokenOrSendError(context, req); } public static getJWT(context: IChassisContext, req: any, res?: any): IJWT { // extract JWT from headers - or error if missing or invalid let token = this.getBearerTokenOrSendError(context, req, res); if (!token) return null; // decode JWT into JSON return jwt.decode(token) as IJWT; } public static getValidJWT(context: IChassisContext, req: any): IJWT { req.jwt = false; let jwt_plugin: JWTPlugin = context.plugins.get("jwt") as JWTPlugin; // extract JWT from headers - or error if missing or invalid let token = this.getBearerTokenOrSendError(context, req); if (!token) { context.log({ code: "api:jwt:verify:token:missing", headers: _.keys(req.headers), }); return null; } // see https://github.com/auth0/node-jsonwebtoken for options: audience, issuer, jwtid, subject, etc: const decoded: any = jwt.decode(token, { complete: true }); if (!decoded || !decoded.payload) { context.warn({ code: "api:jwt:verify:decode:failed", decoded: jwt_plugin.debug ? decoded : false, }); return null; } if (jwt_plugin.ignoreVerify) { context.log({ code: "api:jwt:verify:ignored", }); return decoded.payload as IJWT; } if (!this.verifyJWT(context, jwt_plugin, decoded, token)) { return null; } return decoded.payload as IJWT; } static verifyJWT( context: IChassisContext, jwt_plugin: JWTPlugin, decoded: any, token: string ): boolean { let verify_options: jwt.VerifyOptions = _.extend( { algorithms: [jwt_plugin.algo] }, _.pick(context.config.jwt, [ "algorithms", "issuer", "jwtid", "maxAge", ]) ) as jwt.VerifyOptions; const ijwt = decoded.payload as IJWT; let verified = null; try { if (jwt_plugin.keycloakCerts) { let key = jwt_plugin.keycloakCerts.getKey(decoded.header.kid); if (key && key.n && key.e) { let pkey = jwt_plugin.keycloakCerts.getPublicKey( key.n, key.e ); // console.log("JWT.keycloakCert: %j", decoded, pkey); verified = jwt.verify(token, pkey, verify_options); context.log({ code: "api:jwt:verify:certificate:keycloak", verified: jwt_plugin.debug ? verified : !!verified, publicKey: pkey, }); } else { context.log({ code: "api:jwt:verify:certificate:keycloak:failed", verified: jwt_plugin.debug ? verified : !!verified, kid: decoded.header.kid, key: key ? key : "missing", }); // console.log("JWT.keycloakCert.missing: %j", decoded); } } else { // console.log("JWT.decoded: %j", decoded); verified = jwt.verify( token, jwt_plugin.getCertificate(), verify_options ); context.log({ code: "api:jwt:verify:certficate", verified: jwt_plugin.debug ? verified : !!verified, verify_options: verify_options, }); } } catch (e) { verified = null; ijwt.error = "api:jwt:error"; context.warn({ code: ijwt.error, error: e, options: verify_options, secretOrCertificate: jwt_plugin.secretOrCertificate, }); } return verified ? true : false; } /** * Check for internal consistency within the JWT * not expiry date/time * not before valid date/time * * @param context * @param jwt * @param res */ sanityCheckOrSendError( context: IChassisContext, jwt: IJWT, res?: any ): boolean { let now = Date.now() / 1000; // now in seconds; // check JWT: has not expired if (now > jwt.exp) { return res ? false : Utils.sendError(context, res, { statusCode: 403, code: "api:jwt:expired", message: "expired", }); } // check JWT: not yet issued if (now < jwt.nbf) { return res ? false : Utils.sendError(context, res, { statusCode: 403, code: "api:jwt:nbf", message: "not active", }); } return true; } public static getBearerTokenOrSendError( context: IChassisContext, req: any, res?: any ) { // check Authorization Bearer header exists let header = req.headers.authorization || ""; let has_token = header.toLowerCase().indexOf(BEARER) == 0; let token = has_token ? header.substring(BEARER.length).trim() : ""; // console.log("getBearerTokenOrSendError: %j --> %j --> %j", header, has_token, token) if (token) return token; // auto-response is optional ... res && Utils.sendError(context, res, { statusCode: 401, code: "api:jwt:token:missing", message: "not-authenticated", }); return null; } /** * JWT must contain a non-empty `subject`. * If claims are empty or not specified, then we return true * Otherwise, each claim must be present in the JWT * @param jwt * @param claims */ public static hasClaims(jwt: any, claims?: any, context?: IChassisContext) { // simple sanity check - no subject if (!jwt || !jwt.sub) { context && context.warn({ code: "api:jwt:subject:missing", message: "missing {{jwt.sub}}", }); return false; } // no claims - so 'allow' if (_.isEmpty(claims)) { context && context.log({ code: "api:jwt:claims:missing", message: "needs no claims", }); return true; } let has_claims = true; let logged = []; // check each claim is present in the JWT _.each(claims, function (claim: any, pattern: string) { assert(_.isString(claim) || _.isArray(claim), "invalid claim type"); if (pattern.indexOf("$.") != 0) pattern = "$." + pattern; // uplift a simple path/pattern into simple jsonata matcher // console.log("pattern: %s -> %s", typeof pattern, pattern); let matcher = jsonata(pattern); // create evaluator for claim let matched: any = matcher.evaluate(jwt); // evaluate claim against JWT has_claims = has_claims && matched; // we must have some matches // if the claims were found / matched ... if (matched && _.isArray(claim)) { let matches = Vars.intersect(matched, claim); // console.log("JWT.intersect.matches: %j -> %j ==> %j", claim, matched, matches); logged.push({ type: "array", pattern: pattern, claim: claim, has_claims: has_claims, matched: matched, matches: matches.length, }); has_claims = has_claims && matches.length > 0; } else if (matched && _.isString(claim)) { let matches = Vars.intersect(matched, [claim]); // console.log("JWT.matched: %j = %j => %s", matched, claim, has_claims); logged.push({ type: "string", pattern: pattern, claim: claim, has_claims: has_claims, matched: matched, matches: matches.length, }); } else { logged.push({ type: "none", pattern: pattern, claim: claim, has_claims: has_claims, matched: matched || false, }); } }); context && context.log({ code: "api:jwt:claims", message: "JWT matches claims", claims: claims, has_claims: has_claims, checked: logged, }); return has_claims; } /** * generate claims from scopes (jsonata format) * * @param rules_scopes * @returns string[] */ public static normalize_scopes(rules_scopes: any): string[] { let all_scopes = []; for (let rs in rules_scopes) { let scope = rules_scopes[rs]; if (_.isArray(scope)) { all_scopes = all_scopes.concat(scope); } else if (_.isString(scope)) { all_scopes = all_scopes.concat(scope.split(",")); } } return all_scopes as []; } // create a set of named jsonata matchers - one for each claim in the path public static claims_matchers(scope_claims: any, claim_rules: any): any { let claims_matchers = {}; _.each(claim_rules, function (matcher_key) { claims_matchers[matcher_key] = scope_claims; }); return claims_matchers; } }