UNPKG

@sap/xssec

Version:

XS Advanced Container Security API for node.js

311 lines (252 loc) 8.63 kB
const { jwtDecode } = require("jwt-decode"); const InvalidJwtError = require("../error/validation/InvalidJwtError"); const { TOKEN_DATE_LEEWAY } = require("../util/constants"); const LRUCache = require("../cache/LRUCache"); /** * @typedef {import('../util/Types').JwtHeader} JwtHeader * @typedef {import('../util/Types').JwtPayload} JwtPayload */ /** * @typedef {object} DecodeCacheConfig * @property {Number} [size] - Size of the cache, defaults to 100 * @property {import("../util/Types").Cache} [impl] - A custom cache instance that is used instead of the default LRUCache. */ class Token { /** * A shared jwt->{header, payload} cache that is used to avoid decoding the same token multiple times. * @type {import("../util/Types").Cache} */ static decodeCache = null; /** * Enables the shared decode cache for tokens. * @param {DecodeCacheConfig} [config] - Optional configuration for the decode cache. */ static enableDecodeCache(config = {}) { if (config.impl) { Token.decodeCache = config.impl; } else { Token.decodeCache = new LRUCache(config?.size || 100); } } /** * Disables the shared decode cache for tokens. */ static disableDecodeCache() { Token.decodeCache = false; } #jwt; /** @type {JwtHeader} */ #header; // parsed header /** @type {JwtPayload} */ #payload; // parsed payload /** * @param {string|null} jwt * @param {object} [content] - optional decoded content * @param {JwtHeader & { [key: string]: any }} [content.header] - Optional parsed header (used instead of decoding jwt parameter if both header/payload are provided) * @param {JwtPayload & { [key: string]: any }} [content.payload] - Optional parsed payload (used instead of decoding jwt parameter if both header/payload are provided) */ constructor(jwt, { header, payload } = {}) { this.#jwt = jwt; if (!header || !payload) { let cached = null; if (Token.decodeCache) { cached = Token.decodeCache.get(jwt); } if (cached != null) { header = cached.header; payload = cached.payload; } else { ({ header, payload } = this.#parseJwt(jwt)); if(Token.decodeCache) { Token.decodeCache.set(jwt, { header, payload }); } } } this.#header = header; this.#payload = payload; } #parseJwt(jwt) { try { return { header: jwtDecode(jwt, { header: true }), payload: jwtDecode(jwt) }; } catch (e) { // do not expose jwt-decode specific error => throw xssec-specific error with suggested status code 401 instead throw new InvalidJwtError(jwt, e); } } get audiences() { if (this.payload.aud) { return Array.isArray(this.payload.aud) ? this.payload.aud : [this.payload.aud]; } else { return null; } } get azp() { return this.payload.azp; } /** * @returns {string|null} clientId used to fetch the token */ get clientId() { if (this.azp) { return this.azp; } if (this.audiences == null || this.audiences.length != 1) { // the fallback to cid only occured if audiences contained exactly 1 element, so we stick to this to be backward-compatible return null; } return this.audiences[0] || this.payload.cid; } get email() { return this.payload.email; } /** * Returns whether the token is expired based on claim exp (expiration time). * There is a 1min leeway after the exp in which the token still counts as valid to account for clock skew. * @return {Boolean} false if token has a positive {@link remainingTime}, true otherwise */ get expired() { return this.remainingTime <= 0; } get expirationDate() { return this.payload.exp ? new Date(this.payload.exp * 1000) : null; } /** * @returns {string|null} family name of the user */ get familyName() { return this.payload.ext_attr?.family_name || this.payload.family_name; } /** * @returns {string|null} first name of the user */ get givenName() { return this.payload.ext_attr?.given_name || this.payload.given_name; } get grantType() { return this.payload.grant_type; } /** @return {JwtHeader} Token header as parsed object */ get header() { return this.#header; } get issuer() { return this.payload.iss; } get issueDate() { return this.payload.iat ? new Date(this.payload.iat * 1000) : null; } /** @return {String} JWT used to construct this Token instance as raw String */ get jwt() { return this.#jwt; } /** * Returns whether the token is not yet valid based on the optional nbf (no use before) claim. * There is a 1min leeway before the nbf in which the token already counts as valid to account for clock skew. * @return {Boolean} true if token has valid nbf date that lies at least one minute in the future, false otherwise * @throws InvalidJwtError when nbf claim exists but is not a valid timestamp value */ get notYetValid() { const nbf = this.payload.nbf; if (nbf == null) { return false; } if (typeof nbf !== "number" || nbf <= 0) { throw new InvalidJwtError(this.jwt, null, `Invalid value '${nbf}' inside 'nbf' claim.`); } const notBeforeDate = new Date(nbf * 1000); if (isNaN(notBeforeDate.getTime())) { throw new InvalidJwtError(this.jwt, null, `Failed to parse 'nbf' claim value '${nbf}' into a valid date.`); } const notValidUntilSeconds = (notBeforeDate.getTime() - Date.now()) / 1000; return notValidUntilSeconds > TOKEN_DATE_LEEWAY; } get origin() { return this.payload.origin; } /** @return {JwtPayload} Token payload as parsed object */ get payload() { return this.#payload; } /** * Returns the remaining time until expiration in seconds based on claim exp (expiration time). * There is a 1min leeway after the exp in which the token still counts as valid to account for clock skew. * @returns seconds until expiration or 0 if expired * @throws InvalidJwtError when exp claim does not have a valid value */ get remainingTime() { const exp = this.payload.exp; if (typeof exp !== "number" || exp <= 0) { throw new InvalidJwtError(this.jwt, null, `Invalid value '${exp}' inside 'exp' claim.`); } const expirationDate = new Date(exp * 1000); if (isNaN(expirationDate.getTime())) { throw new InvalidJwtError(this.jwt, null, `Failed to parse 'exp' claim value '${exp}' into a valid date.`); } const remainingMilliseconds = expirationDate.getTime() - Date.now(); const remainingSeconds = Math.floor(remainingMilliseconds / 1000) + TOKEN_DATE_LEEWAY; return Math.max(0, remainingSeconds); } get subject() { return this.payload.sub; } get userName() { return this.payload.user_name; } get userId() { return this.payload.user_uuid; } // Methods for backward-compatibility getAudiencesArray() { return this.audiences; } getAzp() { return this.azp; } getClientId() { return this.clientId; } getEmail() { return this.email; } getExpirationDate() { return this.expirationDate; } getFamilyName() { return this.familyName; } getGivenName() { return this.givenName; } getGrantType() { return this.grantType; } getHeader() { return this.header; } getIssuedAt() { return this.issueDate; } getIssuer() { if (this.issuer && !this.issuer.startsWith('http')) { return `https://${this.issuer}`; } else { return this.issuer; } } getPayload() { return this.payload; } getSubject() { return this.subject; } getTokenValue() { return this.jwt; } getUserId() { return this.userId; } } module.exports = Token;