From 4d11f54e1906d0abcd5681b963e371a46c4f78a4 Mon Sep 17 00:00:00 2001 From: Stojan Dimitrovski Date: Tue, 2 Jan 2024 12:03:59 +0100 Subject: [PATCH] feat: add jwks support --- .github/workflows/ci.yml | 2 +- package-lock.json | 118 +++++++++++--- package.json | 4 +- src/auth/jwt.ts | 154 ++++++++++++++++--- src/config.ts | 18 ++- src/database/tenant.ts | 37 ++++- src/http/plugins/jwt.ts | 9 +- src/http/routes/object/getSignedObject.ts | 2 +- src/http/routes/object/uploadSignedObject.ts | 2 +- src/http/routes/render/renderSignedImage.ts | 2 +- src/storage/object.ts | 6 +- src/test/jwt.test.ts | 97 ++++++++++++ 12 files changed, 397 insertions(+), 54 deletions(-) create mode 100644 src/test/jwt.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3428753..bc62f8a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: fail-fast: false matrix: platform: [ubuntu-20.04] - node: ['18'] + node: ['20'] runs-on: ${{ matrix.platform }} diff --git a/package-lock.json b/package-lock.json index f84be8ab..d8f83b6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,7 @@ "fs-extra": "^10.0.1", "fs-xattr": "^0.3.1", "ioredis": "^5.2.4", - "jsonwebtoken": "^9.0.0", + "jsonwebtoken": "^9.0.2", "knex": "^2.4.2", "md5-file": "^5.0.0", "pg": "^8.10.0", @@ -56,7 +56,7 @@ "@types/fs-extra": "^9.0.13", "@types/jest": "^29.2.1", "@types/js-yaml": "^4.0.5", - "@types/jsonwebtoken": "^8.5.8", + "@types/jsonwebtoken": "^9.0.5", "@types/mustache": "^4.2.2", "@types/node": "^18.14.6", "@types/pg": "^8.6.4", @@ -4621,9 +4621,9 @@ "dev": true }, "node_modules/@types/jsonwebtoken": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-8.5.8.tgz", - "integrity": "sha512-zm6xBQpFDIDM6o9r6HSgDeIcLy82TKWctCXEPbJJcXb5AKmi5BNNdLXneixK4lplX3PqIVcwLBCGE/kAGnlD4A==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", + "integrity": "sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==", "dev": true, "dependencies": { "@types/node": "*" @@ -7933,14 +7933,20 @@ } }, "node_modules/jsonwebtoken": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz", - "integrity": "sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", "dependencies": { "jws": "^3.2.2", - "lodash": "^4.17.21", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", "ms": "^2.1.1", - "semver": "^7.3.8" + "semver": "^7.5.4" }, "engines": { "node": ">=12", @@ -8140,11 +8146,41 @@ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, "node_modules/lodash.isarguments": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==" }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -8157,6 +8193,11 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "node_modules/lodash.throttle": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", @@ -14115,9 +14156,9 @@ "dev": true }, "@types/jsonwebtoken": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-8.5.8.tgz", - "integrity": "sha512-zm6xBQpFDIDM6o9r6HSgDeIcLy82TKWctCXEPbJJcXb5AKmi5BNNdLXneixK4lplX3PqIVcwLBCGE/kAGnlD4A==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", + "integrity": "sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==", "dev": true, "requires": { "@types/node": "*" @@ -16569,14 +16610,20 @@ } }, "jsonwebtoken": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz", - "integrity": "sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", "requires": { "jws": "^3.2.2", - "lodash": "^4.17.21", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", "ms": "^2.1.1", - "semver": "^7.3.8" + "semver": "^7.5.4" } }, "jwa": { @@ -16735,11 +16782,41 @@ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" }, + "lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, "lodash.isarguments": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==" }, + "lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, "lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -16752,6 +16829,11 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, "lodash.throttle": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", diff --git a/package.json b/package.json index 973c8d62..9b99cafc 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "fs-extra": "^10.0.1", "fs-xattr": "^0.3.1", "ioredis": "^5.2.4", - "jsonwebtoken": "^9.0.0", + "jsonwebtoken": "^9.0.2", "knex": "^2.4.2", "md5-file": "^5.0.0", "pg": "^8.10.0", @@ -69,7 +69,7 @@ "@types/fs-extra": "^9.0.13", "@types/jest": "^29.2.1", "@types/js-yaml": "^4.0.5", - "@types/jsonwebtoken": "^8.5.8", + "@types/jsonwebtoken": "^9.0.5", "@types/mustache": "^4.2.2", "@types/node": "^18.14.6", "@types/pg": "^8.6.4", diff --git a/src/auth/jwt.ts b/src/auth/jwt.ts index ce5e7101..4800ed5d 100644 --- a/src/auth/jwt.ts +++ b/src/auth/jwt.ts @@ -1,8 +1,15 @@ -import { getJwtSecret as getJwtSecretForTenant } from '../database/tenant' +import * as crypto from 'crypto' import jwt from 'jsonwebtoken' + +import { getJwtSecret as getJwtSecretForTenant } from '../database/tenant' import { getConfig } from '../config' -const { isMultitenant, jwtSecret, jwtAlgorithm } = getConfig() +const { isMultitenant, jwtSecret, jwtAlgorithm, jwtJWKS } = getConfig() + +const JWT_HS_ALGOS: jwt.Algorithm[] = ['HS256', 'HS384', 'HS512'] +const JWT_RSA_ALGOS: jwt.Algorithm[] = ['RS256', 'RS384', 'RS512'] +const JWT_ECC_ALGOS: jwt.Algorithm[] = ['ES256', 'ES384', 'ES512'] +const JWT_ED_ALGOS: jwt.Algorithm[] = ['EdDSA'] as unknown as jwt.Algorithm[] // types for EdDSA not yet updated interface jwtInterface { sub?: string @@ -26,25 +33,136 @@ export type SignedUploadToken = { * or querying the multi-tenant database by the given tenantId * @param tenantId */ -export async function getJwtSecret(tenantId: string): Promise { - let secret = jwtSecret +export async function getJwtSecret( + tenantId: string +): Promise<{ secret: string; jwks?: { keys: { kid?: string; kty: string }[] } | null }> { if (isMultitenant) { - secret = await getJwtSecretForTenant(tenantId) + return await getJwtSecretForTenant(tenantId) } - return secret + + return { secret: jwtSecret, jwks: jwtJWKS || null } +} + +export function findJWKFromHeader( + header: jwt.JwtHeader, + secret: string, + jwks: { keys: { kid?: string; kty: string }[] } | null +) { + if (!jwks || !jwks.keys) { + return secret + } + + if (JWT_HS_ALGOS.indexOf(header.alg as jwt.Algorithm) > -1) { + // JWT is using HS, find the proper key + + if (!header.kid && header.alg == jwtAlgorithm) { + // jwt is probably signed with the static secret + return secret + } + + // find the first key without a kid or with the matching kid and the "oct" type + const jwk = jwks.keys.find( + (key) => (!key.kid || key.kid === header.kid) && key.kty === 'oct' && (key as any).k + ) + + if (!jwk) { + // jwt is probably signed with the static secret + return secret + } + + return Buffer.from((jwk as any).k, 'base64') + } + + // jwt is using an asymmetric algorithm + let kty = 'RSA' + + if (JWT_ECC_ALGOS.indexOf(header.alg as jwt.Algorithm) > -1) { + kty = 'EC' + } else if (JWT_ED_ALGOS.indexOf(header.alg as jwt.Algorithm) > -1) { + kty = 'OKP' + } + + // find the first key with a matching kid (or no kid if none is specified in the JWT header) and the correct key type + const jwk = jwks.keys.find((key) => { + return ((!key.kid && !header.kid) || key.kid === header.kid) && key.kty === kty + }) + + if (!jwk) { + // couldn't find a matching JWK, try to use the secret + return secret + } + + return crypto.createPublicKey({ + format: 'jwk', + key: jwk, + }) +} + +function getJWTVerificationKey( + secret: string, + jwks: { keys: { kid?: string; kty: string }[] } | null +): jwt.GetPublicKeyOrSecret { + return (header: jwt.JwtHeader, callback: jwt.SigningKeyCallback) => { + let result: any = null + + try { + result = findJWKFromHeader(header, secret, jwks) + } catch (e: any) { + callback(e) + return + } + + callback(null, result) + } +} + +export function getJWTAlgorithms( + secret: string, + jwks: { keys: { kid?: string; kty: string }[] } | null +) { + let algorithms: jwt.Algorithm[] + + if (jwks && jwks.keys && jwks.keys.length) { + const hasRSA = jwks.keys.find((key) => key.kty === 'RSA') + const hasECC = jwks.keys.find((key) => key.kty === 'EC') + const hasED = jwks.keys.find( + (key) => key.kty === 'OKP' && ((key as any).crv === 'Ed25519' || (key as any).crv === 'Ed448') + ) + + algorithms = [ + jwtAlgorithm as jwt.Algorithm, + ...(hasRSA ? JWT_RSA_ALGOS : []), + ...(hasECC ? JWT_ECC_ALGOS : []), + ...(hasED ? JWT_ED_ALGOS : []), + ] + } else { + algorithms = [jwtAlgorithm as jwt.Algorithm] + } + + return algorithms } /** * Verifies if a JWT is valid * @param token * @param secret + * @param jwks */ -export function verifyJWT(token: string, secret: string): Promise { +export function verifyJWT( + token: string, + secret: string, + jwks?: { keys: { kid?: string; kty: string }[] } | null +): Promise { return new Promise((resolve, reject) => { - jwt.verify(token, secret, { algorithms: [jwtAlgorithm as jwt.Algorithm] }, (err, decoded) => { - if (err) return reject(err) - resolve(decoded as jwt.JwtPayload & T) - }) + jwt.verify( + token, + getJWTVerificationKey(secret, jwks || null), + { algorithms: getJWTAlgorithms(secret, jwks || null) }, + (err, decoded) => { + if (err) return reject(err) + resolve(decoded as jwt.JwtPayload & T) + } + ) }) } @@ -76,13 +194,13 @@ export function signJWT( * Extract the owner (user) from the provided JWT * @param token * @param secret + * @param jwks */ -export async function getOwner(token: string, secret: string): Promise { - const decodedJWT = await verifyJWT(token, secret) +export async function getOwner( + token: string, + secret: string, + jwks: { keys: { kid?: string; kty: string }[] } | null +): Promise { + const decodedJWT = await verifyJWT(token, secret, jwks) return (decodedJWT as jwtInterface)?.sub } - -export async function getRole(token: string, secret: string): Promise { - const decodedJWT = await verifyJWT(token, secret) - return (decodedJWT as jwtInterface)?.role -} diff --git a/src/config.ts b/src/config.ts index bef69508..b4b70977 100644 --- a/src/config.ts +++ b/src/config.ts @@ -21,6 +21,7 @@ type StorageConfigType = { isMultitenant: boolean jwtSecret: string jwtAlgorithm: string + jwtJWKS?: { keys: { kid?: string; kty: string }[] } multitenantDatabaseUrl?: string databaseURL: string databaseSSLRootCert?: string @@ -98,10 +99,10 @@ function getOptionalIfMultitenantConfigFromEnv(key: string): string | undefined : getConfigFromEnv(key) } -export function getConfig(): StorageConfigType { +export function getConfig() { dotenv.config() - return { + const config: StorageConfigType = { version: getOptionalConfigFromEnv('VERSION') || '0.0.0', keepAliveTimeout: parseInt(getOptionalConfigFromEnv('SERVER_KEEP_ALIVE_TIMEOUT') || '61', 10), headersTimeout: parseInt(getOptionalConfigFromEnv('SERVER_HEADERS_TIMEOUT') || '65', 10), @@ -231,4 +232,17 @@ export function getConfig(): StorageConfigType { getOptionalConfigFromEnv('TUS_USE_FILE_VERSION_SEPARATOR') === 'true', enableDefaultMetrics: getOptionalConfigFromEnv('ENABLE_DEFAULT_METRICS') === 'true', } + + const jwtJWKS = getOptionalConfigFromEnv('JWT_JWKS') || null + + if (jwtJWKS) { + try { + const parsed = JSON.parse(jwtJWKS) + config.jwtJWKS = parsed + } catch (e: any) { + throw new Error('Unable to parse JWT_JWKS value to JSON') + } + } + + return config } diff --git a/src/database/tenant.ts b/src/database/tenant.ts index bda0da94..b437b4c8 100644 --- a/src/database/tenant.ts +++ b/src/database/tenant.ts @@ -14,6 +14,13 @@ interface TenantConfig { fileSizeLimit: number features: Features jwtSecret: string + jwks?: { + keys: { + kid?: string + kty: string + // other fields are present too but are dependent on kid, alg and other fields, cast to unknown to access those + }[] + } | null serviceKey: string serviceKeyPayload: { role: string @@ -86,6 +93,7 @@ export async function getTenantConfig(tenantId: string): Promise { database_url, file_size_limit, jwt_secret, + jwt_jwks, service_key, feature_image_transformation, database_pool_url, @@ -97,7 +105,21 @@ export async function getTenantConfig(tenantId: string): Promise { const serviceKeyPayload = await verifyJWT<{ role: string }>(serviceKey, jwtSecret) - const config = { + let jwks: TenantConfig['jwks'] | null = null + + if (jwt_jwks) { + try { + jwks = JSON.parse(jwt_jwks) + } catch (e) { + throw new StorageBackendError( + 'Bad JWKS config in Tenant Config', + 400, + `Tenant for config ${tenantId} contains bad JSON in jwt_jwks` + ) + } + } + + const config: TenantConfig = { anonKey: decrypt(anon_key), databaseUrl: decrypt(database_url), databasePoolUrl: database_pool_url ? decrypt(database_pool_url) : undefined, @@ -112,6 +134,11 @@ export async function getTenantConfig(tenantId: string): Promise { }, }, } + + if (jwks) { + config.jwks = jwks + } + await cacheTenantConfigAndRunMigrations(tenantId, config) return config } @@ -169,9 +196,11 @@ export async function getServiceKey(tenantId: string): Promise { * Get the jwt key from the tenant config * @param tenantId */ -export async function getJwtSecret(tenantId: string): Promise { - const { jwtSecret } = await getTenantConfig(tenantId) - return jwtSecret +export async function getJwtSecret( + tenantId: string +): Promise<{ secret: string; jwks: TenantConfig['jwks'] | null }> { + const { jwtSecret, jwks } = await getTenantConfig(tenantId) + return { secret: jwtSecret, jwks: jwks || null } } /** diff --git a/src/http/plugins/jwt.ts b/src/http/plugins/jwt.ts index de4b8c2a..6aaacd2e 100644 --- a/src/http/plugins/jwt.ts +++ b/src/http/plugins/jwt.ts @@ -9,14 +9,17 @@ declare module 'fastify' { } } +const BEARER = /^Bearer\s+/i + export const jwt = fastifyPlugin(async (fastify) => { fastify.decorateRequest('jwt', '') fastify.addHook('preHandler', async (request, reply) => { - request.jwt = (request.headers.authorization || '').substring('Bearer '.length) + request.jwt = (request.headers.authorization || '').replace(BEARER, '') + + const { secret, jwks } = await getJwtSecret(request.tenantId) - const jwtSecret = await getJwtSecret(request.tenantId) try { - const owner = await getOwner(request.jwt, jwtSecret) + const owner = await getOwner(request.jwt, secret, jwks || null) request.owner = owner } catch (err: any) { request.log.error({ error: err }, 'unable to get owner') diff --git a/src/http/routes/object/getSignedObject.ts b/src/http/routes/object/getSignedObject.ts index 8b6b40bb..816f70e5 100644 --- a/src/http/routes/object/getSignedObject.ts +++ b/src/http/routes/object/getSignedObject.ts @@ -56,7 +56,7 @@ export default async function routes(fastify: FastifyInstance) { const { download } = request.query let payload: SignedToken - const jwtSecret = await getJwtSecret(request.tenantId) + const { secret: jwtSecret } = await getJwtSecret(request.tenantId) try { payload = (await verifyJWT(token, jwtSecret)) as SignedToken diff --git a/src/http/routes/object/uploadSignedObject.ts b/src/http/routes/object/uploadSignedObject.ts index 31e80292..8feb1e07 100644 --- a/src/http/routes/object/uploadSignedObject.ts +++ b/src/http/routes/object/uploadSignedObject.ts @@ -70,7 +70,7 @@ export default async function routes(fastify: FastifyInstance) { // Validate sender const { token } = request.query - const jwtSecret = await getJwtSecret(request.tenantId) + const { secret: jwtSecret } = await getJwtSecret(request.tenantId) let payload: SignedUploadToken try { diff --git a/src/http/routes/render/renderSignedImage.ts b/src/http/routes/render/renderSignedImage.ts index 39120255..db6cc2fa 100644 --- a/src/http/routes/render/renderSignedImage.ts +++ b/src/http/routes/render/renderSignedImage.ts @@ -53,7 +53,7 @@ export default async function routes(fastify: FastifyInstance) { const { download } = request.query let payload: SignedToken - const jwtSecret = await getJwtSecret(request.tenantId) + const { secret: jwtSecret } = await getJwtSecret(request.tenantId) try { payload = (await verifyJWT(token, jwtSecret)) as SignedToken diff --git a/src/storage/object.ts b/src/storage/object.ts index f1a4d318..ab666ac6 100644 --- a/src/storage/object.ts +++ b/src/storage/object.ts @@ -459,7 +459,7 @@ export class ObjectStorage { const urlParts = url.split('/') const urlToSign = decodeURI(urlParts.splice(3).join('/')) - const jwtSecret = await getJwtSecret(this.db.tenantId) + const { secret: jwtSecret } = await getJwtSecret(this.db.tenantId) const token = await signJWT({ url: urlToSign, ...metadata }, jwtSecret, expiresIn) let urlPath = 'object' @@ -496,7 +496,7 @@ export class ObjectStorage { const nameSet = new Set(results.map(({ name }) => name)) - const jwtSecret = await getJwtSecret(this.db.tenantId) + const { secret: jwtSecret } = await getJwtSecret(this.db.tenantId) return Promise.all( paths.map(async (path) => { @@ -547,7 +547,7 @@ export class ObjectStorage { const urlParts = url.split('/') const urlToSign = decodeURI(urlParts.splice(4).join('/')) - const jwtSecret = await getJwtSecret(this.db.tenantId) + const { secret: jwtSecret } = await getJwtSecret(this.db.tenantId) const token = await signJWT({ owner, url: urlToSign }, jwtSecret, expiresIn) return `/object/upload/sign/${urlToSign}?token=${token}` diff --git a/src/test/jwt.test.ts b/src/test/jwt.test.ts new file mode 100644 index 00000000..936f8eac --- /dev/null +++ b/src/test/jwt.test.ts @@ -0,0 +1,97 @@ +import * as crypto from 'crypto' + +import { verifyJWT } from '../auth' + +describe('JWT', () => { + describe('verifyJWT with JWKS', () => { + const keys: { + type?: string + options?: any + alg: string + kid?: string + publicKey: any + privateKey: any + }[] = [ + { type: 'rsa', options: { modulusLength: 2048 }, alg: 'RS256' }, + { type: 'ec', options: { namedCurve: 'P-256' }, alg: 'ES256' }, + //{ type: 'ed25519', options: null, alg: 'EdDSA' }, + ].map((desc, i) => ({ + kid: i.toString(), + ...desc, + ...crypto.generateKeyPairSync(desc.type as any, (desc.options || undefined) as any), + })) + + const hmacPrivateKeyWithoutKid = crypto.randomBytes(256 / 8).toString('hex') + + // without kid, so the value from the secret argument will be taken + keys.push({ + alg: 'HS256', + privateKey: Buffer.from(hmacPrivateKeyWithoutKid, 'utf-8'), + publicKey: { + export: (options?: any) => ({ + doesntmatter: 'wontbeused', + }), + }, + }) + + const hmacPrivateKeyWithKid = crypto.randomBytes(256 / 8).toString('hex') + + // with kid, so the value from the JWKS will be used + keys.push({ + alg: 'HS256', + kid: keys.length.toString(), + privateKey: Buffer.from(hmacPrivateKeyWithKid, 'utf-8'), + publicKey: { + export: (options?: any) => ({ + kty: 'oct', + k: Buffer.from(hmacPrivateKeyWithKid, 'utf-8').toString('base64url'), + }), + }, + }) + + const jwks = { + keys: keys.map( + ({ publicKey, kid }, i) => + ({ + ...(publicKey as unknown as crypto.KeyObject).export({ format: 'jwk' }), + kid, + } as any) + ), + } + + keys.forEach(({ privateKey, alg, kid }) => { + const iat = Math.trunc(Date.now() / 1000) + const exp = iat + 60 + + const parts = [ + Buffer.from(JSON.stringify({ typ: 'JWT', kid, alg }), 'utf-8').toString('base64url'), + Buffer.from(JSON.stringify({ sub: 'abcdef', iat, exp }), 'utf-8').toString('base64url'), + ] + + if (alg !== 'HS256') { + const sign = crypto.createSign('SHA256') + sign.write(parts.join('.')) + sign.end() + + if (alg === 'ES256') { + parts.push( + sign.sign(Object.assign(privateKey, { dsaEncoding: 'ieee-p1363' }), 'base64url') + ) + } else { + parts.push(sign.sign(privateKey, 'base64url')) + } + } else { + const hmac = crypto.createHmac('SHA256', privateKey) + hmac.update(parts.join('.')) + parts.push(hmac.digest('base64url')) + } + + const jwt = parts.join('.') + + test(`it should verify a JWT with alg=${alg}`, async () => { + const result = await verifyJWT(jwt, hmacPrivateKeyWithoutKid, jwks) + expect(result.sub).toEqual('abcdef') + }) + }) + }) +})