UNPKG

embassy

Version:

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

523 lines (496 loc) 19.7 kB
/* * Embassy * Copyright (c) 2017-2021 Tom Shawver */ import base64 from 'base64-js' import jwt from 'jsonwebtoken' import { KeyNotFoundError } from './errors/KeyNotFoundError' import { ScopeNotFoundError } from './errors/ScopeNotFoundError' import { TokenParseError } from './errors/TokenParseError' import { Claims, DomainScopes, JWTHeader, PrivateKeyDefinition, ScopeComponents, Serializable, SigningAlgorithm, TokenOptions, TokenSigningOptions, TokenVerificationOptions, symmetricAlgorithms } from './types' import { forEachScope, splitCombined } from './util' export class Token { private scopesLastUpdate = 0 private opts: TokenOptions = { refreshScopesAfterMs: 1000, keys: {}, domainScopes: {}, expiresInSecs: 3600 } private token: string private domainBlobs: Record<string, Uint8Array> = {} public claims: Claims = {} public header: JWTHeader /** * 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: TokenOptions = {}) { const { token, claims, ...rest } = opts this.claims = claims || {} this.opts = { ...this.opts, ...rest } if (token) { this.token = token const decoded = jwt.decode(token, { complete: true, json: true }) if (!decoded) throw new 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<T extends Serializable>( domain: string, key: string ): T | undefined { return this.claims.opt?.[domain]?.[key] } /** * Grants the given scope to this token within the specified domain. * * @param domain - The domain that contains the scope to be granted * @param scope - The name of the scope to be granted */ async grantScope(domain: string, scope: string): Promise<void> /** * Grants the given scope to this token. If the scope string contains a `|` * character, the string up to the first `|` will be used as the domain under * which the scope will be grouped. Otherwise, the default domain of `app` * will be used. * * @param combined - The scope string to be granted, optionally containing a * domain in the format `domain|scopeName` */ async grantScope(combined: string): Promise<void> async grantScope(domainOrCombined: string, scope?: string): Promise<void> { const parts = scope ? { domain: domainOrCombined, key: scope } : splitCombined(domainOrCombined) const comps = await 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 } /** * Grants the given array of scopes to this token. * * @param domainScopes - A map of domains to arrays of scopes in that domain * to be granted to the token */ async grantScopes(domainScopes: DomainScopes): Promise<void> /** * Grants the given array of scopes to this token. * * @param combined - An array of strings in the format `domain|scope`. If the * domain portion is missing, the default scope of "app" will be used. */ async grantScopes(combined: string[]): Promise<void> async grantScopes( domainScopesOrCombined: DomainScopes | string[] ): Promise<void> { await forEachScope(domainScopesOrCombined, async (domain, scope) => { await this.grantScope(domain, scope) }) } /** * Checks to see whether this token contains the given scope. * * @param domain - The domain that contains the scope to be checked * @param scope - The scope to be checked * @returns `true` if the scope is included in this Token; `false` otherwise. */ async hasScope(domain: string, scope: string): Promise<boolean> /** * Checks to see whether this token contains the given scope. If the scope * string contains a `|` character, the string up to the first `|` will be * used as the domain under which the scope will be grouped. Otherwise, the * default domain of `app` will be used. * * @param combined - The scope string to be checked, optionally containing a * domain in the format `domain|scopeName` */ async hasScope(combined: string): Promise<boolean> async hasScope(domainOrCombined: string, scope?: string): Promise<boolean> { const parts = scope ? { domain: domainOrCombined, key: scope } : splitCombined(domainOrCombined) const comps = await 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 } /** * Checks this token for the given scopes in the DomainScopes map. * * @param domainScopes - A map of domains to arrays of scopes in that domain * to be checked * @returns `true` if every scope of every domain exists on this Token; * `false` otherwise */ async hasScopes(domainScopes: DomainScopes): Promise<boolean> /** * Checks this token for the given scopes in the provided array. * * @param combined - An array of strings in the format `domain|scope`. If the * domain portion is missing, the default scope of "app" will be used. * @returns `true` if every scope of every domain exists on this Token; * `false` otherwise */ async hasScopes(combined: string[]): Promise<boolean> async hasScopes( domainScopesOrCombined: DomainScopes | string[] ): Promise<boolean> { let hasAll = true await forEachScope( domainScopesOrCombined, async (domain, scope, breakFn) => { const hasScope = await this.hasScope(domain, scope) if (!hasScope) { hasAll = false breakFn() } } ) return hasAll } /** * Revokes a scope that has been previously granted. This method is idempotent * and will not fail when revoking scopes that have not been granted. * * @param domain - The domain that contains the scope to be revoked * @param scope - The name of the scope to be revoked */ async revokeScope(domain: string, scope: string): Promise<void> /** * Revokes a scope that has been previously granted. This method is idempotent * and will not fail when revoking scopes that have not been granted. * * If the scope string contains a `|` character, the string up to the first * `|` will be used as the domain under which the scope will be grouped. * Otherwise, the default domain of `app` will be used. * * @param combined - The scope string to be revoked, optionally containing a * domain in the format `domain|scopeName` */ async revokeScope(combined: string): Promise<void> async revokeScope(domainOrCombined: string, scope?: string): Promise<void> { const parts = scope ? { domain: domainOrCombined, key: scope } : splitCombined(domainOrCombined) const comps = await 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 } } /** * Revokes a list of scopes that have been previously granted. This method is * idempotent and will not fail when revoking scopes that have not been * granted. * * @param domainScopes - A map of domains to arrays of scopes in that domain * to be revoked from the token */ async revokeScopes(domainScopes: DomainScopes): Promise<void> /** * Revokes a list of scopes that have been previously granted. This method is * idempotent and will not fail when revoking scopes that have not been * granted. * * @param combined - An array of strings in the format `domain|scope`. If the * domain portion is missing, the default scope of "app" will be used. */ async revokeScopes(combined: string[]): Promise<void> async revokeScopes( domainScopesOrCombined: DomainScopes | string[] ): Promise<void> { await forEachScope(domainScopesOrCombined, async (domain, scope) => { await 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: string, key: string, val: Serializable): void { 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. */ async sign(kid: string, opts: TokenSigningOptions = {}): Promise<string> { 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: jwt.SignOptions = { 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 = await this.getPrivateKeyDefinition(kid) return new Promise((resolve, reject) => { params.algorithm = key.algorithm as jwt.Algorithm jwt.sign(this.claims, key.privateKey, params, (err, token) => { if (err) return reject(err) this.token = token const decoded = jwt.decode(token, { complete: true }) this.header = (decoded as Record<string, any>).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 */ async verify(opts: TokenVerificationOptions = {}): Promise<Claims> { 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: jwt.VerifyOptions = { 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 as jwt.Algorithm[] }) } const key = opts.key || (await this.getVerificationKey(this.header.kid, this.header.alg)) return new Promise((resolve, reject) => { jwt.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`. */ private 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.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. */ private encodeBlobs(): void { const segments = Object.keys(this.domainBlobs).map((domain) => { const encodedScopes = base64.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 */ private getBlob(domain: string, minBytes?: number): Uint8Array { let blob: Uint8Array = 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. */ private async getPrivateKeyDefinition( kid: string ): Promise<PrivateKeyDefinition> { const keys = this.opts.keys if (!keys[kid]?.privateKey && this.opts.getPrivateKey) { const privKeyDef = await this.opts.getPrivateKey(kid) if (!keys[kid]) keys[kid] = privKeyDef else keys[kid].privateKey = privKeyDef.privateKey } if (!keys[kid]?.privateKey) { throw new 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. */ private async getVerificationKey( kid: string, algorithm: SigningAlgorithm ): Promise<string> { // Symmetric keys are private. Check for that first const isSymmetric = symmetricAlgorithms.includes(algorithm) if (isSymmetric) { const privDef = await this.getPrivateKeyDefinition(kid) return privDef.privateKey } // Asymmetric keys are public keys. Check that next. const keys = this.opts.keys if (!keys[kid]?.publicKey && this.opts.getPublicKey) { const publicKey = await this.opts.getPublicKey(kid) if (!keys[kid]) keys[kid] = { algorithm, publicKey } else keys[kid].publicKey = publicKey } if (!keys[kid]?.publicKey) { throw new 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 */ private async getScopeComponents( domain: string, scope: string, resize = false ): Promise<ScopeComponents> { const comps: Partial<ScopeComponents> = {} const idx = await 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 as ScopeComponents } /** * 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. */ private async getScopeIndex( domain: string, scope: string, noRetry = false ): Promise<number> { 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(`Scope does not exist: ${domain}|${scope}`) } const domainScopes = await this.opts.refreshScopes() this.scopesLastUpdate = Date.now() Object.assign(this.opts.domainScopes, domainScopes) return this.getScopeIndex(domain, scope, true) } return map[scope] } }