embassy
Version:
Simple JSON Web Tokens (JWT) with embedded scopes for services
403 lines • 18.9 kB
JavaScript
"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