UNPKG

embassy

Version:

Simple JSON Web Tokens (JWT) with embedded scopes for services

403 lines 18.9 kB
"use strict"; /* * Embassy * Copyright (c) 2017-2021 Tom Shawver */ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __rest = (this && this.__rest) || function (s, e) { var t = {}; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; } return t; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.Token = void 0; const base64_js_1 = __importDefault(require("base64-js")); const jsonwebtoken_1 = __importDefault(require("jsonwebtoken")); const KeyNotFoundError_1 = require("./errors/KeyNotFoundError"); const ScopeNotFoundError_1 = require("./errors/ScopeNotFoundError"); const TokenParseError_1 = require("./errors/TokenParseError"); const types_1 = require("./types"); const util_1 = require("./util"); class Token { /** * Creates a new Token. * * @param opts - An object mapping of configuration objects * @throws {@link TokenParseError} * Thrown if the provided token cannot be parsed */ constructor(opts = {}) { this.scopesLastUpdate = 0; this.opts = { refreshScopesAfterMs: 1000, keys: {}, domainScopes: {}, expiresInSecs: 3600 }; this.domainBlobs = {}; this.claims = {}; const { token, claims } = opts, rest = __rest(opts, ["token", "claims"]); this.claims = claims || {}; this.opts = Object.assign(Object.assign({}, this.opts), rest); if (token) { this.token = token; const decoded = jsonwebtoken_1.default.decode(token, { complete: true, json: true }); if (!decoded) throw new TokenParseError_1.TokenParseError(`Bad token: ${token}`); this.header = decoded.header; this.claims = decoded.payload; } this.decodeBlobs(); } /** * Gets the content of a domain-specific option. * * @param domain - The domain containing the requested option * @param key - The name of the option for which the value should be retrieved * @returns The value of the requested option, or undefined */ getOption(domain, key) { var _a, _b; return (_b = (_a = this.claims.opt) === null || _a === void 0 ? void 0 : _a[domain]) === null || _b === void 0 ? void 0 : _b[key]; } grantScope(domainOrCombined, scope) { return __awaiter(this, void 0, void 0, function* () { const parts = scope ? { domain: domainOrCombined, key: scope } : util_1.splitCombined(domainOrCombined); const comps = yield this.getScopeComponents(parts.domain, parts.key, true); // Bitwise OR together the original byte with the bit mask to set the bit comps.blob[comps.offset] = comps.byte | comps.mask; this.domainBlobs[parts.domain] = comps.blob; }); } grantScopes(domainScopesOrCombined) { return __awaiter(this, void 0, void 0, function* () { yield util_1.forEachScope(domainScopesOrCombined, (domain, scope) => __awaiter(this, void 0, void 0, function* () { yield this.grantScope(domain, scope); })); }); } hasScope(domainOrCombined, scope) { return __awaiter(this, void 0, void 0, function* () { const parts = scope ? { domain: domainOrCombined, key: scope } : util_1.splitCombined(domainOrCombined); const comps = yield this.getScopeComponents(parts.domain, parts.key); // Use bitwise AND to determine if the byte contains the bit mask return comps.byte ? (comps.byte & comps.mask) === comps.mask : false; }); } hasScopes(domainScopesOrCombined) { return __awaiter(this, void 0, void 0, function* () { let hasAll = true; yield util_1.forEachScope(domainScopesOrCombined, (domain, scope, breakFn) => __awaiter(this, void 0, void 0, function* () { const hasScope = yield this.hasScope(domain, scope); if (!hasScope) { hasAll = false; breakFn(); } })); return hasAll; }); } revokeScope(domainOrCombined, scope) { return __awaiter(this, void 0, void 0, function* () { const parts = scope ? { domain: domainOrCombined, key: scope } : util_1.splitCombined(domainOrCombined); const comps = yield this.getScopeComponents(parts.domain, parts.key); if (comps.byte) { // Use bitwise AND with the inverse of the bit mask to unset the scope bit comps.blob[comps.offset] = comps.byte & ~comps.mask; this.domainBlobs[parts.domain] = comps.blob; } }); } revokeScopes(domainScopesOrCombined) { return __awaiter(this, void 0, void 0, function* () { yield util_1.forEachScope(domainScopesOrCombined, (domain, scope) => __awaiter(this, void 0, void 0, function* () { yield this.revokeScope(domain, scope); })); }); } /** * Sets a domain-specific option on this token. Options are meant for holding * non-boolean settings. For boolean values, consider defining a new scope for * this domain. All options are stored in the `opt` claim at the top level. * * @param domain - The domain in which to set the given option * @param key - The name of the option to be set * @param val - The value for the option */ setOption(domain, key, val) { if (!this.claims.opt) this.claims.opt = {}; if (!this.claims.opt[domain]) this.claims.opt[domain] = {}; this.claims.opt[domain][key] = val; } /** * Serializes the claims within this Token and signs them cryptographically. * The result is an encoded JWT token string. * * @param kid - An identifier for the key with which to sign this token. The * private key or HMAC secret must either exist in the `keys` map passed in * the constructor options, or be retrievable by the `getPrivateKey` function * provided to the constructor. * @param opts - Options to configure the token signing process * @returns the signed and encoded token string. * @throws {@link Error} * Throws if options.subject was not specified, and the 'sub' claim has not * been set. A subject is a required claim for a valid JWT. */ sign(kid, opts = {}) { return __awaiter(this, void 0, void 0, function* () { if (!opts.subject && !this.claims.sub) { throw new Error('A subject is required to sign this token'); } this.encodeBlobs(); delete this.claims.exp; const audience = opts.audience || this.opts.audience; const issuer = opts.issuer || this.opts.issuer; const params = Object.assign(Object.assign(Object.assign({ expiresIn: opts.expiresInSecs || this.opts.expiresInSecs, noTimestamp: !!opts.noTimestamp, header: opts.header || {} }, (opts.subject && { subject: opts.subject })), (audience && { audience })), (issuer && { issuer })); params.header['kid'] = kid; const key = yield this.getPrivateKeyDefinition(kid); return new Promise((resolve, reject) => { params.algorithm = key.algorithm; jsonwebtoken_1.default.sign(this.claims, key.privateKey, params, (err, token) => { if (err) return reject(err); this.token = token; const decoded = jsonwebtoken_1.default.decode(token, { complete: true }); this.header = decoded.header; resolve(token); }); }); }); } /** * Verifies a token's validity by checking its signature, expiration time, * and other conditions. * * @param opts - Options to customize how the token is verified * @returns the token's claims when successfully verified. * @throws {@link TokenExpiredError} * Thrown when a token has passed the date in its `exp` claim * @throws {@link JsonWebTokenError} * Thrown for most verification issues, such as a missing or invalid * signature, or mismatched audience or issuer strings * @throws {@link NotBeforeError} * Thrown when the date in the `nbf` claim is in the future */ verify(opts = {}) { return __awaiter(this, void 0, void 0, function* () { if (!this.token) throw new Error('No token string to verify'); const audience = opts.audience || this.claims.aud || this.opts.audience; const issuer = opts.issuer || this.claims.iss || this.opts.issuer; const params = Object.assign(Object.assign(Object.assign(Object.assign(Object.assign({ ignoreExpiration: opts.ignoreExpiration || false, clockTolerance: opts.clockToleranceSecs || 5 }, (opts.maxAgeSecs && { maxAge: `${opts.maxAgeSecs * 1000}` })), (audience && { audience })), (issuer && { issuer })), (opts.nonce && { nonce: opts.nonce })), (opts.algorithms && { algorithms: opts.algorithms })); const key = opts.key || (yield this.getVerificationKey(this.header.kid, this.header.alg)); return new Promise((resolve, reject) => { jsonwebtoken_1.default.verify(this.token, key, params, (err) => { if (err) reject(err); else resolve(this.claims); }); }); }); } /** * Decodes the `scope` claim into a mapping of domain string to byte array, * stored in `this.domainBlobs`. */ decodeBlobs() { if (this.claims.scope) { const segments = this.claims.scope.split(/[;:]/); for (let i = 0; i < segments.length; i += 2) { this.domainBlobs[segments[i]] = base64_js_1.default.toByteArray(segments[i + 1]); } } } /** * Encodes `this.domainBlobs` into a single string in the format * `domain1:base64perms1;domain2:base64perms2` (etc) and stores it into the * `scope` claim. */ encodeBlobs() { const segments = Object.keys(this.domainBlobs).map((domain) => { const encodedScopes = base64_js_1.default.fromByteArray(this.domainBlobs[domain]); return `${domain}:${encodedScopes}`; }); this.claims.scope = segments.join(';'); } /** * Retrieves a byte array from the set of domain blobs, resizing it if * necessary. * * @param domain - The domain string for which to get the binary scopes blob * @param minBytes - The number of bytes the resulting array should have in * it, at minimum * @returns The byte array for the given domain */ getBlob(domain, minBytes) { let blob = this.domainBlobs[domain]; if (!blob || blob.length < (minBytes || 0)) { const array = Array.from(blob || []); while (array.length < (minBytes || 0)) array.push(0); blob = Uint8Array.from(array); } return blob; } /** * Retrieves the private key definition for a specified key ID. This function * will first attempt to pull the private key (and associated algorithm) from * the `keys` object passed to the constructor, and if not found there, will * call the `getPrivateKey` function passed to the constructor if one exists. * If a private key is found through that method, it will be saved back to the * provided `keys` object to avoid calling `getPrivateKey` for the same key ID * again. * * @param kid - The key ID of the private key definition to be retrieved * @returns the appropriate private key definition * @throws {@link KeyNotFoundError} * Thrown if the function expires all avenues by which to locate the * referenced private key. */ getPrivateKeyDefinition(kid) { var _a, _b; return __awaiter(this, void 0, void 0, function* () { const keys = this.opts.keys; if (!((_a = keys[kid]) === null || _a === void 0 ? void 0 : _a.privateKey) && this.opts.getPrivateKey) { const privKeyDef = yield this.opts.getPrivateKey(kid); if (!keys[kid]) keys[kid] = privKeyDef; else keys[kid].privateKey = privKeyDef.privateKey; } if (!((_b = keys[kid]) === null || _b === void 0 ? void 0 : _b.privateKey)) { throw new KeyNotFoundError_1.KeyNotFoundError(`Private key "${kid}" could not be found`); } const { privateKey, algorithm } = keys[kid]; return { privateKey, algorithm }; }); } /** * Retrieves the public key for a specified key ID. The algorithm is required * so that the function knows to look for a private "key" for symmetric * signing algorithms, and a public key for asymmetric. If the key not does * exist in the `keys` option passed to the constructor, this function will * attempt to use `getPublicKey` if it exists, caching the successful result * back in the `keys` object for next time. * * @param kid - The key ID of the key to be retrieved * @param algorithm - The algorithm that the key is meant for * @returns the specified verification key, in PEM-encoded format for * asymmetric public keys or the HMAC secret string. * @throws {@link KeyNotFoundError} * Thrown if the function expires all avenues by which to locate the * referenced key. */ getVerificationKey(kid, algorithm) { var _a, _b; return __awaiter(this, void 0, void 0, function* () { // Symmetric keys are private. Check for that first const isSymmetric = types_1.symmetricAlgorithms.includes(algorithm); if (isSymmetric) { const privDef = yield this.getPrivateKeyDefinition(kid); return privDef.privateKey; } // Asymmetric keys are public keys. Check that next. const keys = this.opts.keys; if (!((_a = keys[kid]) === null || _a === void 0 ? void 0 : _a.publicKey) && this.opts.getPublicKey) { const publicKey = yield this.opts.getPublicKey(kid); if (!keys[kid]) keys[kid] = { algorithm, publicKey }; else keys[kid].publicKey = publicKey; } if (!((_b = keys[kid]) === null || _b === void 0 ? void 0 : _b.publicKey)) { throw new KeyNotFoundError_1.KeyNotFoundError(`Public key "${kid}" could not be found`); } return keys[kid].publicKey; }); } /** * Gets the binary components of an individual scope, targeting the bit * that can be read or changed to interact with the encoded scope. * * @param domain - The domain containing the target scope * @param scope - The name of the scope for which to retrieve the components * @param resize - `true` to resize the resulting blob to fit the chosen scope * bit; `false` to return it in the currently stored size * @returns The components of the given scope */ getScopeComponents(domain, scope, resize = false) { return __awaiter(this, void 0, void 0, function* () { const comps = {}; const idx = yield this.getScopeIndex(domain, scope); comps.idx = idx; comps.offset = Math.floor(idx / 8); comps.blob = this.getBlob(domain, resize ? comps.offset + 1 : 0); comps.byte = comps.blob[comps.offset]; comps.mask = Math.pow(2, idx % 8); return comps; }); } /** * Gets the index of the specified scope from the domainScopes map. If it's * not found, this method attempts to refresh that map with the refreshScopes * method before throwing a ScopeNotFoundError. * * @param domain - The domain containing the target scope * @param scope - The name of the target scope * @param noRetry - `true` to not attempt to refresh the domainScopes map and * retry this function; `false` to throw {@link ScopeNotFoundError} * immediately when a scope doesn't exist in domainScopes. * @returns the index of the target permission * @throws {@link ScopeNotFoundError} * Thrown if the given scope does not exist in the domainScopes object and * did not appear when refreshing the domainScopes. */ getScopeIndex(domain, scope, noRetry = false) { return __awaiter(this, void 0, void 0, function* () { const map = this.opts.domainScopes[domain] || {}; if (!(scope in map)) { const updateAfter = this.scopesLastUpdate + this.opts.refreshScopesAfterMs; if (noRetry || !this.opts.refreshScopes || Date.now() < updateAfter) { throw new ScopeNotFoundError_1.ScopeNotFoundError(`Scope does not exist: ${domain}|${scope}`); } const domainScopes = yield this.opts.refreshScopes(); this.scopesLastUpdate = Date.now(); Object.assign(this.opts.domainScopes, domainScopes); return this.getScopeIndex(domain, scope, true); } return map[scope]; }); } } exports.Token = Token; //# sourceMappingURL=Token.js.map