diff --git a/package-lock.json b/package-lock.json index 545e88e65..d65821b2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,7 +43,6 @@ "@types/graphql": "14.5.0", "@types/inflected": "1.1.29", "@types/inquirer": "^6.5.0", - "@types/jsonwebtoken": "8.5.1", "@types/mustache": "4.1.0", "@types/nedb": "^1.8.11", "@types/needle": "^2.0.4", @@ -97,8 +96,8 @@ "inquirer": "^7.0.0", "isomorphic-fetch": "^2.2.1", "iterall": "1.3.0", + "jose": "4.8.1", "js-yaml": "^3.14.0", - "jsonwebtoken": "8.5.1", "jwks-rsa": "2.0.3", "mocha-skip-if": "0.0.3", "mock-jwks": "1.0.3", @@ -2692,39 +2691,31 @@ } }, "node_modules/@boostercloud/framework-common-helpers": { - "version": "0.30.2", + "version": "0.30.4", + "resolved": "https://registry.npmjs.org/@boostercloud/framework-common-helpers/-/framework-common-helpers-0.30.4.tgz", + "integrity": "sha512-YegGfNLmTjkGak+caYZiiJ50ydQzE5Isnom+PMvJlODA0fYzaZTQRY3/U5WcXLDeyIKCOa/FpItR3nGD52ctPQ==", "dev": true, - "license": "Apache-2.0", "dependencies": { - "@boostercloud/framework-types": "^0.30.2", + "@boostercloud/framework-types": "^0.30.4", "child-process-promise": "^2.2.1", - "tslib": "2.3.1" + "tslib": "2.4.0" } }, - "node_modules/@boostercloud/framework-common-helpers/node_modules/tslib": { - "version": "2.3.1", - "dev": true, - "license": "0BSD" - }, "node_modules/@boostercloud/framework-provider-local": { "resolved": "packages/framework-provider-local", "link": true }, "node_modules/@boostercloud/framework-types": { - "version": "0.30.2", + "version": "0.30.4", + "resolved": "https://registry.npmjs.org/@boostercloud/framework-types/-/framework-types-0.30.4.tgz", + "integrity": "sha512-ll4ejHaDLxOFLJ1LDpzlQPoEtryx+wR5OmeTfd2T+BphgrYkq+OH5CwElSPqp5IrBJhrx4iHy6fSd3TBqfmruQ==", "dev": true, - "license": "Apache-2.0", "dependencies": { "@types/graphql": "14.5.0", - "tslib": "2.3.1", + "tslib": "2.4.0", "uuid": "8.3.2" } }, - "node_modules/@boostercloud/framework-types/node_modules/tslib": { - "version": "2.3.1", - "dev": true, - "license": "0BSD" - }, "node_modules/@cdktf/hcl2cdk": { "version": "0.8.0-pre.10", "license": "MPL-2.0", @@ -6520,7 +6511,8 @@ }, "node_modules/@panva/asn1.js": { "version": "1.0.0", - "license": "MIT", + "resolved": "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz", + "integrity": "sha512-UdkG3mLEqXgnlKsWanWcgb6dOjUzJ+XC5f+aWw30qrtjxeNUSfKX1cd5FBzOaXQumoe9nIqeZUvrRJS03HCCtw==", "engines": { "node": ">=10.13.0" } @@ -7235,13 +7227,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/jsonwebtoken": { - "version": "8.5.1", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/keyv": { "version": "3.1.4", "license": "MIT", @@ -17534,14 +17519,9 @@ } }, "node_modules/jose": { - "version": "2.0.5", - "license": "MIT", - "dependencies": { - "@panva/asn1.js": "^1.0.0" - }, - "engines": { - "node": ">=10.13.0 < 13 || >=13.7.0" - }, + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.8.1.tgz", + "integrity": "sha512-+/hpTbRcCw9YC0TOfN1W47pej4a9lRmltdOVdRLz5FP5UvUq3CenhXjQK7u/8NdMIIShMXYAh9VLPhc7TjhvFw==", "funding": { "url": "https://github.com/sponsors/panva" } @@ -18435,6 +18415,20 @@ "node": ">=10 < 13 || >=14" } }, + "node_modules/jwks-rsa/node_modules/jose": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.5.tgz", + "integrity": "sha512-BAiDNeDKTMgk4tvD0BbxJ8xHEHBZgpeRZ1zGPPsitSyMgjoMWiLGYAE7H7NpP5h0lPppQajQs871E8NHUrzVPA==", + "dependencies": { + "@panva/asn1.js": "^1.0.0" + }, + "engines": { + "node": ">=10.13.0 < 13 || >=13.7.0" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/jws": { "version": "3.2.2", "license": "MIT", @@ -21463,6 +21457,20 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/openid-client/node_modules/jose": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.5.tgz", + "integrity": "sha512-BAiDNeDKTMgk4tvD0BbxJ8xHEHBZgpeRZ1zGPPsitSyMgjoMWiLGYAE7H7NpP5h0lPppQajQs871E8NHUrzVPA==", + "dependencies": { + "@panva/asn1.js": "^1.0.0" + }, + "engines": { + "node": ">=10.13.0 < 13 || >=13.7.0" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/optimism": { "version": "0.10.3", "license": "MIT", @@ -26804,12 +26812,12 @@ }, "packages/framework-provider-local": { "name": "@boostercloud/framework-provider-local", - "version": "0.30.2", + "version": "0.30.4", "dev": true, "license": "Apache-2.0", "dependencies": { - "@boostercloud/framework-common-helpers": "^0.30.2", - "@boostercloud/framework-types": "^0.30.2", + "@boostercloud/framework-common-helpers": "^0.30.4", + "@boostercloud/framework-types": "^0.30.4", "@types/nedb": "^1.8.11", "nedb": "^1.8.0", "tslib": "2.4.0" @@ -28279,25 +28287,21 @@ } }, "@boostercloud/framework-common-helpers": { - "version": "0.30.2", + "version": "0.30.4", + "resolved": "https://registry.npmjs.org/@boostercloud/framework-common-helpers/-/framework-common-helpers-0.30.4.tgz", + "integrity": "sha512-YegGfNLmTjkGak+caYZiiJ50ydQzE5Isnom+PMvJlODA0fYzaZTQRY3/U5WcXLDeyIKCOa/FpItR3nGD52ctPQ==", "dev": true, "requires": { - "@boostercloud/framework-types": "^0.30.2", + "@boostercloud/framework-types": "^0.30.4", "child-process-promise": "^2.2.1", - "tslib": "2.3.1" - }, - "dependencies": { - "tslib": { - "version": "2.3.1", - "dev": true - } + "tslib": "2.4.0" } }, "@boostercloud/framework-provider-local": { "version": "file:packages/framework-provider-local", "requires": { - "@boostercloud/framework-common-helpers": "^0.30.2", - "@boostercloud/framework-types": "^0.30.2", + "@boostercloud/framework-common-helpers": "^0.30.4", + "@boostercloud/framework-types": "^0.30.4", "@types/express": "4.17.12", "@types/faker": "5.1.5", "@types/nedb": "^1.8.11", @@ -28313,18 +28317,14 @@ } }, "@boostercloud/framework-types": { - "version": "0.30.2", + "version": "0.30.4", + "resolved": "https://registry.npmjs.org/@boostercloud/framework-types/-/framework-types-0.30.4.tgz", + "integrity": "sha512-ll4ejHaDLxOFLJ1LDpzlQPoEtryx+wR5OmeTfd2T+BphgrYkq+OH5CwElSPqp5IrBJhrx4iHy6fSd3TBqfmruQ==", "dev": true, "requires": { "@types/graphql": "14.5.0", - "tslib": "2.3.1", + "tslib": "2.4.0", "uuid": "8.3.2" - }, - "dependencies": { - "tslib": { - "version": "2.3.1", - "dev": true - } } }, "@cdktf/hcl2cdk": { @@ -30959,7 +30959,9 @@ } }, "@panva/asn1.js": { - "version": "1.0.0" + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@panva/asn1.js/-/asn1.js-1.0.0.tgz", + "integrity": "sha512-UdkG3mLEqXgnlKsWanWcgb6dOjUzJ+XC5f+aWw30qrtjxeNUSfKX1cd5FBzOaXQumoe9nIqeZUvrRJS03HCCtw==" }, "@protobufjs/aspromise": { "version": "1.1.2" @@ -31480,12 +31482,6 @@ "version": "0.0.29", "dev": true }, - "@types/jsonwebtoken": { - "version": "8.5.1", - "requires": { - "@types/node": "*" - } - }, "@types/keyv": { "version": "3.1.4", "requires": { @@ -38090,10 +38086,9 @@ "version": "0.15.0" }, "jose": { - "version": "2.0.5", - "requires": { - "@panva/asn1.js": "^1.0.0" - } + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.8.1.tgz", + "integrity": "sha512-+/hpTbRcCw9YC0TOfN1W47pej4a9lRmltdOVdRLz5FP5UvUq3CenhXjQK7u/8NdMIIShMXYAh9VLPhc7TjhvFw==" }, "js-tokens": { "version": "4.0.0" @@ -38668,6 +38663,16 @@ "jose": "^2.0.5", "limiter": "^1.1.5", "lru-memoizer": "^2.1.2" + }, + "dependencies": { + "jose": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.5.tgz", + "integrity": "sha512-BAiDNeDKTMgk4tvD0BbxJ8xHEHBZgpeRZ1zGPPsitSyMgjoMWiLGYAE7H7NpP5h0lPppQajQs871E8NHUrzVPA==", + "requires": { + "@panva/asn1.js": "^1.0.0" + } + } } }, "jws": { @@ -40713,6 +40718,16 @@ "make-error": "^1.3.6", "object-hash": "^2.0.1", "oidc-token-hash": "^5.0.1" + }, + "dependencies": { + "jose": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/jose/-/jose-2.0.5.tgz", + "integrity": "sha512-BAiDNeDKTMgk4tvD0BbxJ8xHEHBZgpeRZ1zGPPsitSyMgjoMWiLGYAE7H7NpP5h0lPppQajQs871E8NHUrzVPA==", + "requires": { + "@panva/asn1.js": "^1.0.0" + } + } } }, "optimism": { diff --git a/packages/application-tester/package.json b/packages/application-tester/package.json index d6f4c14c5..fe8a28867 100644 --- a/packages/application-tester/package.json +++ b/packages/application-tester/package.json @@ -38,9 +38,9 @@ "apollo-link-ws": "1.0.20", "apollo-utilities": "1.3.4", "cross-fetch": "3.1.5", - "jsonwebtoken": "8.5.1", "subscriptions-transport-ws": "0.9.18", - "ws": "7.4.5" + "ws": "7.4.5", + "jose": "4.8.1" }, "devDependencies": { "chai": "4.2.0", diff --git a/packages/application-tester/src/token-helper.ts b/packages/application-tester/src/token-helper.ts index cec196a91..e3d89eabf 100644 --- a/packages/application-tester/src/token-helper.ts +++ b/packages/application-tester/src/token-helper.ts @@ -1,6 +1,6 @@ import * as fs from 'fs' -import * as jwt from 'jsonwebtoken' import * as path from 'path' +import { SignJWT, KeyLike, importPKCS8 } from 'jose' type TokenOptions = { expiresIn?: number @@ -13,31 +13,41 @@ type TokenOptions = { * The keyset file is expecgted to be located in "/keys/private.key file" */ export class TokenHelper { - private privateKey: Buffer + private privateKey: string + constructor() { - this.privateKey = fs.readFileSync(path.join(__dirname, '..', 'keys', 'private.key')) + this.privateKey = fs.readFileSync(path.join(__dirname, '..', 'keys', 'private.key')).toString('utf8') } - public forUser(email: string, role?: string, tokenOptions?: TokenOptions): string { + + public async forUser(email: string, role?: string, tokenOptions?: TokenOptions): Promise { const keyid = 'booster' const issuer = 'booster' - const options = { - algorithm: 'RS256', - subject: email, - issuer, - keyid, - } as jwt.SignOptions - if (tokenOptions?.expiresIn !== undefined) { - options['expiresIn'] = tokenOptions?.expiresIn - } - if (tokenOptions?.notBefore) { - options['notBefore'] = tokenOptions?.notBefore - } + const payload = { id: email, email, ...tokenOptions?.customClaims, } const rolesClaim = role ? { 'booster:role': role } : {} - return jwt.sign({ ...payload, ...rolesClaim }, this.privateKey, options) + + let tokenBuilder = new SignJWT({ ...payload, ...rolesClaim }) + .setProtectedHeader({ + alg: 'RS256', + kid: keyid, + }) + .setIssuedAt() + .setIssuer(issuer) + .setSubject(email) + + if (tokenOptions?.expiresIn !== undefined) { + tokenBuilder = tokenBuilder.setExpirationTime(tokenOptions.expiresIn) + } + + if (tokenOptions?.notBefore !== undefined) { + tokenBuilder = tokenBuilder.setNotBefore(tokenOptions.notBefore) + } + + const key: KeyLike = await importPKCS8(this.privateKey, 'RS256') + return await tokenBuilder.sign(key) } } diff --git a/packages/framework-core/package.json b/packages/framework-core/package.json index 058d30950..3c5765183 100644 --- a/packages/framework-core/package.json +++ b/packages/framework-core/package.json @@ -38,8 +38,7 @@ "graphql-subscriptions": "1.2.1", "inflected": "2.1.0", "iterall": "1.3.0", - "jsonwebtoken": "8.5.1", - "jwks-rsa": "2.0.3", + "jose": "4.8.1", "reflect-metadata": "0.1.13", "tslib": "2.4.0", "validator": "13.7.0" @@ -48,7 +47,6 @@ "@boostercloud/metadata-booster": "^0.30.4", "@types/faker": "5.1.5", "@types/inflected": "1.1.29", - "@types/jsonwebtoken": "8.5.1", "@types/validator": "13.1.3", "chai": "4.2.0", "chai-as-promised": "7.1.1", @@ -57,6 +55,7 @@ "mock-jwks": "1.0.3", "nock": "11.8.2", "sinon": "9.2.3", - "sinon-chai": "3.5.0" + "sinon-chai": "3.5.0", + "rewire": "5.0.0" } } diff --git a/packages/framework-core/src/booster-token-verifier.ts b/packages/framework-core/src/booster-token-verifier.ts index 1736323d1..ebaef824e 100644 --- a/packages/framework-core/src/booster-token-verifier.ts +++ b/packages/framework-core/src/booster-token-verifier.ts @@ -5,15 +5,18 @@ import { UserEnvelope, BoosterConfig, } from '@boostercloud/framework-types' -import { NotBeforeError, TokenExpiredError } from 'jsonwebtoken' +import { errors } from 'jose' export class BoosterTokenVerifier { public constructor(private config: BoosterConfig) {} public async verify(token: string): Promise { + const sanitizedToken = token.replace('Bearer ', '').trim() const userEnvelopes = await Promise.allSettled( this.config.tokenVerifiers.map((tokenVerifier) => - tokenVerifier.verify(token).then((decodedToken) => Promise.resolve(tokenVerifier.toUserEnvelope(decodedToken))) + tokenVerifier + .verify(sanitizedToken) + .then((decodedToken) => Promise.resolve(tokenVerifier.toUserEnvelope(decodedToken))) ) ) const winner = userEnvelopes.find((result) => result.status === 'fulfilled') @@ -39,11 +42,13 @@ export class BoosterTokenVerifier { } private getTokenNotBeforeErrors(results: Array>): Array { - return this.getErrors(results).filter((result) => result.reason instanceof NotBeforeError) + return this.getErrors(results).filter( + (result) => result.reason instanceof errors.JWTClaimValidationFailed && result.reason.claim === 'nbf' + ) } private getTokenExpiredErrors(results: Array>): Array { - return this.getErrors(results).filter((result) => result.reason instanceof TokenExpiredError) + return this.getErrors(results).filter((result) => result.reason instanceof errors.JWTExpired) } private getErrors(results: Array>): Array { diff --git a/packages/framework-core/src/services/token-verifiers/encrypted-token-verifier.ts b/packages/framework-core/src/services/token-verifiers/encrypted-token-verifier.ts new file mode 100644 index 000000000..843f16f11 --- /dev/null +++ b/packages/framework-core/src/services/token-verifiers/encrypted-token-verifier.ts @@ -0,0 +1,19 @@ +import { RoleBasedTokenVerifier } from './role-based-token-verifier' +import { jwtDecrypt, KeyLike } from 'jose' +import { DecodedToken } from '@boostercloud/framework-types' + +export class EncryptedTokenVerifier extends RoleBasedTokenVerifier { + public constructor( + issuer: string, + readonly decryptionKeyResolver: Promise, + rolesClaim?: string + ) { + super(issuer, rolesClaim) + } + + public async verify(token: string): Promise { + const decryptionKey = await this.decryptionKeyResolver + const { payload, protectedHeader } = await jwtDecrypt(token, decryptionKey) + return { payload, header: protectedHeader } + } +} diff --git a/packages/framework-core/src/services/token-verifiers/index.ts b/packages/framework-core/src/services/token-verifiers/index.ts index 7dba7ef4c..eab8c3530 100644 --- a/packages/framework-core/src/services/token-verifiers/index.ts +++ b/packages/framework-core/src/services/token-verifiers/index.ts @@ -1,4 +1,3 @@ -export * from './utilities' export * from './jwks-uri-token-verifier' export * from './public-key-token-verifier' export * from './role-based-token-verifier' diff --git a/packages/framework-core/src/services/token-verifiers/jwks-uri-token-verifier.ts b/packages/framework-core/src/services/token-verifiers/jwks-uri-token-verifier.ts index 20e8a70dc..e25814ca0 100644 --- a/packages/framework-core/src/services/token-verifiers/jwks-uri-token-verifier.ts +++ b/packages/framework-core/src/services/token-verifiers/jwks-uri-token-verifier.ts @@ -1,6 +1,7 @@ -import { DecodedToken } from '@boostercloud/framework-types' -import { getJwksClient, getKeyWithClient, verifyJWT } from './utilities' import { RoleBasedTokenVerifier } from './role-based-token-verifier' +import { createRemoteJWKSet, jwtVerify } from 'jose' +import { URL } from 'url' +import { DecodedToken } from '@boostercloud/framework-types' /** * Environment variables that are used to configure a default JWKs URI Token Verifier @@ -14,13 +15,21 @@ export const JWT_ENV_VARS = { } export class JwksUriTokenVerifier extends RoleBasedTokenVerifier { - public constructor(readonly issuer: string, readonly jwksUri: string, rolesClaim?: string) { - super(rolesClaim) + public constructor( + issuer: string, + readonly jwksUri: string, + rolesClaim?: string, + readonly algorithm: string = 'RS256' + ) { + super(issuer, rolesClaim) } public async verify(token: string): Promise { - const client = getJwksClient(this.jwksUri) - const key = getKeyWithClient.bind(this, client) - return verifyJWT(token, this.issuer, key) + const jwks = createRemoteJWKSet(new URL(this.jwksUri)) + const { payload, protectedHeader } = await jwtVerify(token, jwks, { + issuer: this.issuer, + algorithms: [this.algorithm], + }) + return { payload, header: protectedHeader } } } diff --git a/packages/framework-core/src/services/token-verifiers/public-key-token-verifier.ts b/packages/framework-core/src/services/token-verifiers/public-key-token-verifier.ts index 8a85b7529..3ae3c6bbc 100644 --- a/packages/framework-core/src/services/token-verifiers/public-key-token-verifier.ts +++ b/packages/framework-core/src/services/token-verifiers/public-key-token-verifier.ts @@ -1,14 +1,21 @@ -import { DecodedToken } from '@boostercloud/framework-types' -import { verifyJWT } from './utilities' import { RoleBasedTokenVerifier } from './role-based-token-verifier' +import { importSPKI, jwtVerify } from 'jose' +import { DecodedToken } from '@boostercloud/framework-types' export class PublicKeyTokenVerifier extends RoleBasedTokenVerifier { - public constructor(readonly issuer: string, readonly publicKeyResolver: Promise, rolesClaim?: string) { - super(rolesClaim) + public constructor( + issuer: string, + readonly publicKeyResolver: Promise, + rolesClaim?: string, + readonly algorithm: string = 'RS256' + ) { + super(issuer, rolesClaim) } public async verify(token: string): Promise { const key = await this.publicKeyResolver - return verifyJWT(token, this.issuer, key) + const publicKey = await importSPKI(key, this.algorithm) + const { payload, protectedHeader } = await jwtVerify(token, publicKey, { issuer: this.issuer }) + return { payload, header: protectedHeader } } } diff --git a/packages/framework-core/src/services/token-verifiers/role-based-token-verifier.ts b/packages/framework-core/src/services/token-verifiers/role-based-token-verifier.ts index 2ffea3346..108ae97d8 100644 --- a/packages/framework-core/src/services/token-verifiers/role-based-token-verifier.ts +++ b/packages/framework-core/src/services/token-verifiers/role-based-token-verifier.ts @@ -1,4 +1,4 @@ -import { DecodedToken, TokenVerifier, UserEnvelope } from '@boostercloud/framework-types' +import { DecodedToken, TokenVerifier, UserEnvelope, UUID } from '@boostercloud/framework-types' export const DEFAULT_ROLES_CLAIM = 'custom:role' @@ -16,21 +16,21 @@ function rolesFromTokenRole(rolesClaim: unknown): Array { } export abstract class RoleBasedTokenVerifier implements TokenVerifier { - public constructor(readonly rolesClaim: string = DEFAULT_ROLES_CLAIM) {} + public constructor(readonly issuer: string, readonly rolesClaim: string = DEFAULT_ROLES_CLAIM) {} abstract verify(token: string): Promise public toUserEnvelope(decodedToken: DecodedToken): UserEnvelope { - const payload = decodedToken.payload - const username = payload?.email || payload?.phone_number || payload.sub - const id = payload.sub + const { payload, header } = decodedToken + const id = payload.sub ?? (UUID.generate() as string) + const username = (payload.email ?? payload.phone_number ?? payload.sub ?? id) as string const roles = rolesFromTokenRole(payload[this.rolesClaim]) return { id, username, roles, - claims: decodedToken.payload, - header: decodedToken.header, + claims: payload, + header, } } } diff --git a/packages/framework-core/src/services/token-verifiers/utilities.ts b/packages/framework-core/src/services/token-verifiers/utilities.ts deleted file mode 100644 index e735ef5d3..000000000 --- a/packages/framework-core/src/services/token-verifiers/utilities.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { DecodedToken } from '@boostercloud/framework-types' -import * as jwt from 'jsonwebtoken' -import { JwksClient, SigningKey } from 'jwks-rsa' - -/** - * Initializes a jwksRSA client that can be used to get the public key of a JWKS URI using the - * `getKeyWithClient` function. - * - * @param jwksUri The JWKS URI - * @returns A JwksRSA client - */ -export function getJwksClient(jwksUri: string): JwksClient { - const jwksRSA = require('jwks-rsa') // Manually loading the default export here to be able to stub it - return jwksRSA({ - jwksUri, - cache: true, - cacheMaxAge: 15 * 60 * 1000, // 15 Minutes, at least to be equal to AWS max lambda limit runtime - }) -} - -/** - * Initializes a function that can be used to get the public key from a JWKS URI with the signature - * required by the `verifyJWT` function. You can create a client using the `getJwksClient` function. - * - * @param client A JwksClient instance - * @param header The JWT header - * @param callback The callback function that will be called when the public key is ready - * @returns A function that can be used to get the public key - */ -export function getKeyWithClient(client: JwksClient, header: jwt.JwtHeader, callback: jwt.SigningKeyCallback): void { - if (!header.kid) { - callback(new Error('JWT kid not found')) - return - } - client.getSigningKey(header.kid, function (err: Error | null, key: SigningKey) { - if (err) { - callback(err) - return - } - callback(null, key.getPublicKey()) - }) -} - -/** - * Verifies a JWT token using a key or key resolver function and returns a Booster UserEnvelope. - * - * @param token The token to verify - * @param issuer The issuer of the token - * @param key The public key to use to verify the token or a function that will resolve a jwksUri to get the public key. The function can be generated using the `getKeyWithClient` function. - * @param rolesClaim The name of the claim containing the roles - * @returns A promise that resolves to the UserEnvelope object - */ -export async function verifyJWT( - token: string, - issuer: string, - key: jwt.Secret | jwt.GetPublicKeyOrSecret -): Promise { - const sanitizedToken = token.replace('Bearer ', '') // Remove the 'Bearer' prefix from the token - - return await new Promise((resolve, reject) => { - jwt.verify( - sanitizedToken, - key, - { - algorithms: ['RS256'], - issuer, - complete: true, // To return headers, payload and other useful token information - }, - (err, decoded) => { - if (err) { - return reject(err) - } - if (!decoded) { - return reject(new Error('The token could not be decoded')) - } - return resolve(decoded as DecodedToken) - } - ) - }) -} diff --git a/packages/framework-core/test/booster-token-verifier.test.ts b/packages/framework-core/test/booster-token-verifier.test.ts index 0618a261d..991821e31 100644 --- a/packages/framework-core/test/booster-token-verifier.test.ts +++ b/packages/framework-core/test/booster-token-verifier.test.ts @@ -216,10 +216,10 @@ describe('the "verifyToken" method', () => { const verifyFunction = boosterTokenVerifier.verify(token) - await expect(verifyFunction).to.eventually.be.rejectedWith('jwt expired') + await expect(verifyFunction).to.eventually.be.rejectedWith(/JWTExpired: "exp" claim timestamp check failed/) }) - it('fails if current time is before the notBefore claim of the token ', async () => { + it('fails if current time is before the notBefore claim of the token', async () => { const token = jwks.token({ sub: userId, iss: issuer, @@ -231,7 +231,9 @@ describe('the "verifyToken" method', () => { const verifyFunction = boosterTokenVerifier.verify(token) - await expect(verifyFunction).to.eventually.be.rejectedWith('jwt not active') + await expect(verifyFunction).to.eventually.be.rejectedWith( + /JWTClaimValidationFailed: "nbf" claim timestamp check failed/ + ) }) it("fails if extra validation doesn't match", async () => { diff --git a/packages/framework-core/test/services/token-verifiers/encrypted-token-verifier.test.ts b/packages/framework-core/test/services/token-verifiers/encrypted-token-verifier.test.ts new file mode 100644 index 000000000..fa5f77bf6 --- /dev/null +++ b/packages/framework-core/test/services/token-verifiers/encrypted-token-verifier.test.ts @@ -0,0 +1,51 @@ +import { fake } from 'sinon' +import { EncryptedTokenVerifier } from '../../../src/services/token-verifiers/encrypted-token-verifier' +import { expect } from '../../expect' + +const rewire = require('rewire') +const jose = rewire('jose') + +describe('EncryptedTokenVerifier', () => { + context('when no rolesclaim is provided', () => { + it('resolves the decryption key and calls `jwtDecrypt`', async () => { + const fakeDecryptionKey = { type: 'fakeDecryptionKey' } + const decryptionKeyResolver = Promise.resolve(fakeDecryptionKey) + const fakeDecodedToken = { protectedHeader: { kid: '123' }, payload: { sub: '123' } } + const undoRewire = jose.__set__('jwtDecrypt', fake.resolves(fakeDecodedToken)) + + const verifier = new EncryptedTokenVerifier('issuer', decryptionKeyResolver) + + expect(verifier.rolesClaim).to.be.equal('custom:role') // Sets the default rolesClaim + + await expect(verifier.verify('token')).to.eventually.become({ + header: fakeDecodedToken.protectedHeader, + payload: fakeDecodedToken.payload, + }) + + expect(jose.jwtDecrypt).to.have.been.calledWith('token', fakeDecryptionKey) + + undoRewire() + }) + }) + + context('when a rolesclaim is provided', () => { + it('resolves the decryption key and calls `jwtDecrypt` with the right rolesclaim', async () => { + const fakeDecryptionKey = { type: 'fakeDecryptionKey' } + const decryptionKeyResolver = Promise.resolve(fakeDecryptionKey) + const fakeDecodedToken = { protectedHeader: { kid: '123' }, payload: { sub: '123', custom: { role: 'admin' } } } + const undoRewire = jose.__set__('jwtDecrypt', fake.resolves(fakeDecodedToken)) + + const verifier = new EncryptedTokenVerifier('issuer', decryptionKeyResolver, 'customRoles') + + expect(verifier.rolesClaim).to.be.equal('customRoles') + + await expect(verifier.verify('token')).to.eventually.become({ + header: fakeDecodedToken.protectedHeader, + payload: fakeDecodedToken.payload, + }) + + expect(jose.jwtDecrypt).to.have.been.calledWith('token', fakeDecryptionKey) + undoRewire() + }) + }) +}) diff --git a/packages/framework-core/test/services/token-verifiers/jwks-uri-token-verifier.test.ts b/packages/framework-core/test/services/token-verifiers/jwks-uri-token-verifier.test.ts index c721c3c63..9aa4a4649 100644 --- a/packages/framework-core/test/services/token-verifiers/jwks-uri-token-verifier.test.ts +++ b/packages/framework-core/test/services/token-verifiers/jwks-uri-token-verifier.test.ts @@ -1,40 +1,76 @@ import { expect } from '../../expect' import { JwksUriTokenVerifier } from '../../../src/services/token-verifiers/jwks-uri-token-verifier' -import * as utilities from '../../../src/services/token-verifiers/utilities' -import { fake, match, replace, restore } from 'sinon' -import { SigningKeyCallback } from 'jsonwebtoken' -import { DecodedToken } from '@boostercloud/framework-types' +import * as jose from 'jose' +import { fake, replace, restore } from 'sinon' describe('JwksUriTokenVerifier', () => { afterEach(() => { restore() }) - it('builds a key resolver and calls `verifyJWT`', async () => { - const fakeClient = { fakeClient: true } - replace(utilities, 'getJwksClient', fake.returns(fakeClient)) - const fakeGetKey = { fakeGetKey: true } - replace(utilities, 'getKeyWithClient', fake.returns(fakeGetKey)) - const fakeDecodedToken = { header: { kid: '123' }, payload: { sub: '123' } } - const fakeHeader = { header: true } - const fakeCallback = fake() - const fakeVerifyJWT = fake( - ( - _token: unknown, - _issuer: unknown, - getKey: (header: any, callback: SigningKeyCallback) => void - ): Promise => { - getKey(fakeHeader, fakeCallback) - return Promise.resolve(fakeDecodedToken) + context('when no algorithm or rolesClaim are provided', () => { + it('builds a key resolver and calls `jwtVerify` with the right JWKSet and the default algorithm', async () => { + const fakeJWKS = { someKeys: true } + replace(jose, 'createRemoteJWKSet', fake.returns(fakeJWKS)) + const fakeVerifiedToken = { protectedHeader: { kid: '123' }, payload: { sub: '123' } } + replace(jose, 'jwtVerify', fake.resolves(fakeVerifiedToken)) + + const verifier = new JwksUriTokenVerifier('issuer', 'https://example.com/jwks') + + expect(verifier.rolesClaim).to.be.equal('custom:role') // Sets the default rolesClaim + + await expect(verifier.verify('token')).to.eventually.become({ + payload: fakeVerifiedToken.payload, + header: fakeVerifiedToken.protectedHeader, + }) + + expect(jose.createRemoteJWKSet).to.have.been.calledWithMatch({ origin: 'https://example.com/jwks' }) + expect(jose.jwtVerify).to.have.been.calledWith('token', fakeJWKS, { issuer: 'issuer', algorithms: ['RS256'] }) + }) + }) + + context('when rolesClaim is provided', () => { + it('builds a key resolver and calls `jwtVerify` with the right JWKSet and the default algorithm and sets the roleClaims attribute', async () => { + const fakeJWKS = { someKeys: true } + replace(jose, 'createRemoteJWKSet', fake.returns(fakeJWKS)) + const fakeVerifiedToken = { + protectedHeader: { kid: '123' }, + payload: { sub: '123', customRoles: ['role1', 'role2'] }, } - ) - replace(utilities, 'verifyJWT', fakeVerifyJWT) + replace(jose, 'jwtVerify', fake.resolves(fakeVerifiedToken)) + + const verifier = new JwksUriTokenVerifier('issuer', 'https://example.com/jwks', 'customRoles') + + expect(verifier.rolesClaim).to.equal('customRoles') + + await expect(verifier.verify('token')).to.eventually.become({ + payload: fakeVerifiedToken.payload, + header: fakeVerifiedToken.protectedHeader, + }) + + expect(jose.createRemoteJWKSet).to.have.been.calledWithMatch({ origin: 'https://example.com/jwks' }) + expect(jose.jwtVerify).to.have.been.calledWith('token', fakeJWKS, { issuer: 'issuer', algorithms: ['RS256'] }) + }) + }) + + context('when an algorithm is provided', () => { + it('builds a key resolver and calls `jwtVerify` with the right JWKSet and the provided algorithm', async () => { + const fakeJWKS = { someKeys: true } + replace(jose, 'createRemoteJWKSet', fake.returns(fakeJWKS)) + const fakeVerifiedToken = { protectedHeader: { kid: '123' }, payload: { sub: '123' } } + replace(jose, 'jwtVerify', fake.resolves(fakeVerifiedToken)) + + const verifier = new JwksUriTokenVerifier('issuer', 'https://example.com/jwks', undefined, 'ES256') + + expect(verifier.rolesClaim).to.be.equal('custom:role') // Sets the default rolesClaim - const verifier = new JwksUriTokenVerifier('issuer', 'https://example.com/jwks') + await expect(verifier.verify('token')).to.eventually.become({ + payload: fakeVerifiedToken.payload, + header: fakeVerifiedToken.protectedHeader, + }) - await expect(verifier.verify('token')).to.eventually.become(fakeDecodedToken) - expect(utilities.getJwksClient).to.have.been.calledWith('https://example.com/jwks') - expect(utilities.getKeyWithClient).to.have.been.calledWith(fakeClient, fakeHeader, fakeCallback) - expect(utilities.verifyJWT).to.have.been.calledWith('token', 'issuer', match.func) + expect(jose.createRemoteJWKSet).to.have.been.calledWithMatch({ origin: 'https://example.com/jwks' }) + expect(jose.jwtVerify).to.have.been.calledWith('token', fakeJWKS, { issuer: 'issuer', algorithms: ['ES256'] }) + }) }) }) diff --git a/packages/framework-core/test/services/token-verifiers/public-key-token-verifier.test.ts b/packages/framework-core/test/services/token-verifiers/public-key-token-verifier.test.ts index bda5fa071..bfda50837 100644 --- a/packages/framework-core/test/services/token-verifiers/public-key-token-verifier.test.ts +++ b/packages/framework-core/test/services/token-verifiers/public-key-token-verifier.test.ts @@ -1,23 +1,74 @@ import { expect } from '../../expect' import { PublicKeyTokenVerifier } from '../../../src/services/token-verifiers/public-key-token-verifier' -import * as utilities from '../../../src/services/token-verifiers/utilities' import { fake, replace, restore } from 'sinon' +import * as jose from 'jose' describe('PublicKeyTokenVerifier', () => { afterEach(() => { restore() }) - it('resolves the public key and calls `verifyJWT`', async () => { - const fakeDecodedToken = { header: { kid: '123' }, payload: { sub: '123' } } - const fakeVerifyJWT = fake.resolves(fakeDecodedToken) - replace(utilities, 'verifyJWT', fakeVerifyJWT) + context('when no algorithm or rolesclaim are provided', () => { + it('resolves the public key and calls `verifyJWT`', async () => { + const publicKey = 'public key' + const publicKeyResolver = Promise.resolve(publicKey) + const fakeSPKIKey = { type: 'somekey' } + replace(jose, 'importSPKI', fake.resolves(fakeSPKIKey)) + const fakeDecodedToken = { protectedHeader: { kid: '123' }, payload: { sub: '123' } } + replace(jose, 'jwtVerify', fake.resolves(fakeDecodedToken)) - const publicKey = 'public key' - const publicKeyResolver = Promise.resolve(publicKey) - const verifier = new PublicKeyTokenVerifier('https://example.com/jwks', publicKeyResolver) + const verifier = new PublicKeyTokenVerifier('issuer', publicKeyResolver) - await expect(verifier.verify('token')).to.eventually.become(fakeDecodedToken) - expect(fakeVerifyJWT).to.have.been.calledWith('token', 'https://example.com/jwks', publicKey) + expect(verifier.rolesClaim).to.be.equal('custom:role') // Sets the default rolesClaim + + await expect(verifier.verify('token')).to.eventually.become(fakeDecodedToken) + + expect(jose.importSPKI).to.have.been.calledWith(publicKey, 'RS256') // default algorithm + expect(jose.jwtVerify).to.have.been.calledWith('token', fakeSPKIKey, { issuer: 'issuer', algorithms: ['RS256'] }) + }) + }) + + context('when an algorithm is provided', () => { + it('resolves the public key and calls `verifyJWT` with the right algorithm', async () => { + const publicKey = 'public key' + const publicKeyResolver = Promise.resolve(publicKey) + const fakeSPKIKey = { type: 'somekey' } + replace(jose, 'importSPKI', fake.resolves(fakeSPKIKey)) + const fakeDecodedToken = { protectedHeader: { kid: '123' }, payload: { sub: '123' } } + replace(jose, 'jwtVerify', fake.resolves(fakeDecodedToken)) + + const verifier = new PublicKeyTokenVerifier('issuer', publicKeyResolver, undefined, 'RS384') + + expect(verifier.rolesClaim).to.be.equal('custom:role') // Sets the default rolesClaim + + await expect(verifier.verify('token')).to.eventually.become(fakeDecodedToken) + + expect(jose.importSPKI).to.have.been.calledWith(publicKey, 'RS384') + expect(jose.jwtVerify).to.have.been.calledWith('token', fakeSPKIKey, { issuer: 'issuer', algorithms: ['RS384'] }) + }) + }) + + context('when a rolesclaim is provided', () => { + it('resolves the public key and calls `verifyJWT` with the right rolesclaim', async () => { + const publicKey = 'public key' + const publicKeyResolver = Promise.resolve(publicKey) + const fakeSPKIKey = { type: 'somekey' } + replace(jose, 'importSPKI', fake.resolves(fakeSPKIKey)) + const fakeDecodedToken = { protectedHeader: { kid: '123' }, payload: { sub: '123', custom: { role: 'admin' } } } + replace(jose, 'jwtVerify', fake.resolves(fakeDecodedToken)) + + const verifier = new PublicKeyTokenVerifier('issuer', publicKeyResolver, 'customRoles') + + expect(verifier.rolesClaim).to.be.equal('customRoles') + + await expect(verifier.verify('token')).to.eventually.become(fakeDecodedToken) + + expect(jose.importSPKI).to.have.been.calledWith(publicKey, 'RS256') + expect(jose.jwtVerify).to.have.been.calledWith('token', fakeSPKIKey, { + issuer: 'issuer', + algorithms: ['RS256'], + audience: 'custom:role', + }) + }) }) }) diff --git a/packages/framework-core/test/services/token-verifiers/role-based-token-verifier.test.ts b/packages/framework-core/test/services/token-verifiers/role-based-token-verifier.test.ts index e9a0d0505..d1a1d9640 100644 --- a/packages/framework-core/test/services/token-verifiers/role-based-token-verifier.test.ts +++ b/packages/framework-core/test/services/token-verifiers/role-based-token-verifier.test.ts @@ -18,6 +18,7 @@ describe('abstract class RoleBasedTokenVerifier', () => { const roles = ['ProUser'] const header = { kid: 'kid123', + alg: 'RS256', } const payload = { sub: 'sub123', @@ -26,7 +27,7 @@ describe('abstract class RoleBasedTokenVerifier', () => { } class UselessTokenVerifier extends RoleBasedTokenVerifier { public constructor() { - super() + super('issuer') } // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -54,6 +55,7 @@ describe('abstract class RoleBasedTokenVerifier', () => { const roles = ['ProUser'] const header = { kid: 'kid123', + alg: 'RS256', } const payload = { sub: 'sub123', diff --git a/packages/framework-core/test/services/token-verifiers/utilities.test.ts b/packages/framework-core/test/services/token-verifiers/utilities.test.ts deleted file mode 100644 index af247ecb7..000000000 --- a/packages/framework-core/test/services/token-verifiers/utilities.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { expect } from '../../expect' -import { fake, match, replace, restore } from 'sinon' -import { getJwksClient, getKeyWithClient, verifyJWT } from '../../../src/services/token-verifiers/utilities' -import { JwksClient } from 'jwks-rsa' -import * as jwt from 'jsonwebtoken' -import { DecodedToken } from '@boostercloud/framework-types' - -describe('function `getJwksClient`', () => { - it('returns a JwksClient instance', () => { - const fakeJwksClient = fake.returns({}) - - // This is a workaround to stub the default export function from `jwks-rsa` - require('jwks-rsa') - delete require.cache[require.resolve('jwks-rsa')] - require.cache[require.resolve('jwks-rsa')] = { - exports: fakeJwksClient, - } as NodeModule - - getJwksClient('https://example.com/jwks') - expect(fakeJwksClient).to.have.been.calledWith({ - jwksUri: 'https://example.com/jwks', - cache: true, - cacheMaxAge: 15 * 60 * 1000, - }) - - // Undo the workaround - delete require.cache[require.resolve('jwks-rsa')] - }) -}) - -describe('function `getKeyWithClient`', () => { - context('when the header does not include a "kid" property', () => { - it('calls the callback function with an error', () => { - const fakeJwksClient = {} as JwksClient - const fakeHeader = {} as jwt.JwtHeader - const fakeCallback = fake() - - getKeyWithClient(fakeJwksClient, fakeHeader, fakeCallback) - - expect(fakeCallback).to.have.been.calledWithMatch({ message: 'JWT kid not found' }) - }) - }) - - context('when getting the public key fails', () => { - it('calls the callback function with an error', () => { - // eslint-disable-next-line @typescript-eslint/ban-types - const fakeGetSigningKeyCallback = fake((_kid: unknown, callback: Function) => - callback(new Error('Error getting public key')) - ) - const fakeJwksClient = { - getSigningKey: fakeGetSigningKeyCallback, - } as unknown as JwksClient - const fakeHeader = { kid: '123' } as jwt.JwtHeader - const fakeCallback = fake() - - getKeyWithClient(fakeJwksClient, fakeHeader, fakeCallback) - - expect(fakeCallback).to.have.been.calledWithMatch({ message: 'Error getting public key' }) - }) - }) - - context('when getting the public key succeeds', () => { - it('calls the callback function with the public key', () => { - // eslint-disable-next-line @typescript-eslint/ban-types - const fakeGetSigningKeyCallback = fake((_kid: string, callback: Function) => - callback(null, { getPublicKey: () => 'public-key' }) - ) - const fakeJwksClient = { - getSigningKey: fakeGetSigningKeyCallback, - } as unknown as JwksClient - const fakeHeader = { kid: '123' } as jwt.JwtHeader - const fakeCallback = fake() - - getKeyWithClient(fakeJwksClient, fakeHeader, fakeCallback) - - expect(fakeCallback).to.have.been.calledWith(null, 'public-key') - }) - }) -}) - -describe('function `verifyJWT`', () => { - afterEach(() => { - restore() - }) - - context('when the token is verified', () => { - it('resolves to a decoded token', async () => { - const fakeToken = 'Bearer token' - const fakeIssuer = 'issuer' - const fakePublicKey = 'public-key' - const fakeDecodedToken = { a: 'token' } as unknown as DecodedToken - // eslint-disable-next-line @typescript-eslint/ban-types - const fakeVerify = fake((_token: unknown, _key: unknown, _options: unknown, callback: Function) => - callback(null, fakeDecodedToken) - ) - replace(jwt, 'verify', fakeVerify) - - await expect(verifyJWT(fakeToken, 'issuer', fakePublicKey)).to.eventually.become(fakeDecodedToken) - - expect(fakeVerify).to.have.been.calledWith( - 'token', - fakePublicKey, - { - algorithms: ['RS256'], - issuer: fakeIssuer, - complete: true, - }, - match.func - ) - }) - }) - - context('when the token is not verified', () => { - it('rejects with an error', async () => { - const fakeToken = 'Bearer token' - const fakeIssuer = 'issuer' - const fakePublicKey = 'public-key' - const fakeError = new Error('Error verifying token') - // eslint-disable-next-line @typescript-eslint/ban-types - const fakeVerify = fake((_token: unknown, _key: unknown, _options: unknown, callback: Function) => - callback(fakeError) - ) - replace(jwt, 'verify', fakeVerify) - - await expect(verifyJWT(fakeToken, 'issuer', fakePublicKey)).to.eventually.be.rejectedWith(fakeError) - - expect(fakeVerify).to.have.been.calledWith( - 'token', - fakePublicKey, - { - algorithms: ['RS256'], - issuer: fakeIssuer, - complete: true, - }, - match.func - ) - }) - }) -}) diff --git a/packages/framework-integration-tests/package.json b/packages/framework-integration-tests/package.json index 2b8d6868f..81ab45692 100644 --- a/packages/framework-integration-tests/package.json +++ b/packages/framework-integration-tests/package.json @@ -30,7 +30,6 @@ "@types/aws-lambda": "8.10.48", "@types/chai-arrays": "2.0.0", "@types/faker": "5.1.5", - "@types/jsonwebtoken": "8.5.1", "@types/ws": "7.4.2", "apollo-cache-inmemory": "1.6.6", "apollo-client": "2.6.10", @@ -43,7 +42,6 @@ "cross-fetch": "3.1.5", "faker": "5.1.0", "graphql-tag": "2.12.4", - "jsonwebtoken": "8.5.1", "jwks-rsa": "2.0.3", "mocha": "8.4.0", "mocha-skip-if": "0.0.3", diff --git a/packages/framework-types/package.json b/packages/framework-types/package.json index 98f6f131e..9504af53c 100644 --- a/packages/framework-types/package.json +++ b/packages/framework-types/package.json @@ -33,18 +33,19 @@ "url": "https://github.com/boostercloud/booster/issues" }, "dependencies": { - "@types/graphql": "14.5.0", "tslib": "2.4.0", "uuid": "8.3.2" }, "devDependencies": { "@boostercloud/metadata-booster": "^0.30.4", + "@types/graphql": "14.5.0", "@types/uuid": "8.3.0", "chai": "4.2.0", "chai-as-promised": "7.1.1", "fast-check": "2.17.0", "mocha": "8.4.0", "sinon": "9.2.3", - "sinon-chai": "3.5.0" + "sinon-chai": "3.5.0", + "jose": "4.8.1" } } diff --git a/packages/framework-types/src/concepts/token-verifier.ts b/packages/framework-types/src/concepts/token-verifier.ts index 6e97ac82e..aae563dcb 100644 --- a/packages/framework-types/src/concepts/token-verifier.ts +++ b/packages/framework-types/src/concepts/token-verifier.ts @@ -1,19 +1,9 @@ import { UserEnvelope } from '../envelope' +import { JWTHeaderParameters, JWTPayload } from 'jose' export interface DecodedToken { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - header: { - kid: string - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [key: string]: any - } - payload: { - sub: string - email?: string - phone_number?: string - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [key: string]: any - } + payload: JWTPayload + header: JWTHeaderParameters } export interface TokenVerifier {