@sap/xssec
Version:
XS Advanced Container Security API for node.js
311 lines (252 loc) • 8.63 kB
JavaScript
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;