diff --git a/.env.template b/.env.template index 423a271a..6fc4743c 100644 --- a/.env.template +++ b/.env.template @@ -3,4 +3,17 @@ GATEWAY_DB_PASSWORD= HASURA_API_URL=http://localhost:8080 +# JWT signing algorithms (JSON array). Default: ["RS256"] +# JWT_ALGORITHMS=["RS256"] + +# JWT configuration. For OIDC/JWKS, include jwk_url, issuer, and audience: +# HASURA_GRAPHQL_JWT_SECRET='{ "jwk_url": "https://your-oidc-provider/.well-known/jwks", "issuer": "https://your-oidc-provider", "audience": "your-client-id" }' HASURA_GRAPHQL_JWT_SECRET= + +# JWT claim paths - customize where user ID, roles, etc. are read from in the JWT. +# Defaults follow Hasura's JWT claims namespace convention. +# These should match your OIDC provider's token mapper configuration. +# JWT_CLAIMS_NAMESPACE=https://hasura.io/jwt/claims +# JWT_CLAIMS_USER_ID=x-hasura-user-id +# JWT_CLAIMS_ALLOWED_ROLES=x-hasura-allowed-roles +# JWT_CLAIMS_DEFAULT_ROLE=x-hasura-default-role diff --git a/package-lock.json b/package-lock.json index 364679b8..9c9730ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "express-rate-limit": "^6.7.0", "helmet": "^7.0.0", "jsonwebtoken": "^9.0.1", + "jwks-rsa": "^3.2.0", "multer": "^1.4.5-lts.1", "nanoid": "^4.0.2", "node-fetch": "^3.3.2", @@ -883,7 +884,6 @@ "version": "1.19.2", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", "integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==", - "dev": true, "dependencies": { "@types/connect": "*", "@types/node": "*" @@ -893,7 +893,6 @@ "version": "3.4.35", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", - "dev": true, "dependencies": { "@types/node": "*" } @@ -923,10 +922,9 @@ "dev": true }, "node_modules/@types/express": { - "version": "4.17.17", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.17.tgz", - "integrity": "sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==", - "dev": true, + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", + "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", @@ -938,7 +936,6 @@ "version": "4.17.35", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.35.tgz", "integrity": "sha512-wALWQwrgiB2AWTT91CB62b6Yt0sNHpznUXeZEcnPU3DRdlDIz74x8Qg1UUYKSVFi+va5vKOLYRBI1bRKiLLKIg==", - "dev": true, "dependencies": { "@types/node": "*", "@types/qs": "*", @@ -949,8 +946,7 @@ "node_modules/@types/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-/K3ds8TRAfBvi5vfjuz8y6+GiAYBZ0x4tXv1Av6CWBWn0IlADc+ZX9pMq7oU0fNQPnBwIZl3rmeLp6SBApbxSQ==", - "dev": true + "integrity": "sha512-/K3ds8TRAfBvi5vfjuz8y6+GiAYBZ0x4tXv1Av6CWBWn0IlADc+ZX9pMq7oU0fNQPnBwIZl3rmeLp6SBApbxSQ==" }, "node_modules/@types/json-schema": { "version": "7.0.12", @@ -958,19 +954,23 @@ "integrity": "sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==" }, "node_modules/@types/jsonwebtoken": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", - "integrity": "sha512-drE6uz7QBKq1fYqqoFKTDRdFCPHd5TCub75BM+D+cMx7NU9hUz7SESLfC2fSCXVFMO5Yj8sOWHuGqPgjc+fz0Q==", - "dev": true, + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", "dependencies": { + "@types/ms": "*", "@types/node": "*" } }, "node_modules/@types/mime": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", - "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", - "dev": true + "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" }, "node_modules/@types/multer": { "version": "1.4.7", @@ -984,8 +984,7 @@ "node_modules/@types/node": { "version": "20.4.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.0.tgz", - "integrity": "sha512-jfT7iTf/4kOQ9S7CHV9BIyRaQqHu67mOjsIQBC3BKZvzvUB6zLxEwJ6sBE3ozcvP8kF6Uk5PXN0Q+c0dfhGX0g==", - "dev": true + "integrity": "sha512-jfT7iTf/4kOQ9S7CHV9BIyRaQqHu67mOjsIQBC3BKZvzvUB6zLxEwJ6sBE3ozcvP8kF6Uk5PXN0Q+c0dfhGX0g==" }, "node_modules/@types/pg": { "version": "8.10.2", @@ -1001,14 +1000,12 @@ "node_modules/@types/qs": { "version": "6.9.7", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", - "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==", - "dev": true + "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==" }, "node_modules/@types/range-parser": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", - "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", - "dev": true + "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==" }, "node_modules/@types/semver": { "version": "7.5.0", @@ -1020,7 +1017,6 @@ "version": "0.17.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.1.tgz", "integrity": "sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==", - "dev": true, "dependencies": { "@types/mime": "^1", "@types/node": "*" @@ -1030,7 +1026,6 @@ "version": "1.15.2", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.2.tgz", "integrity": "sha512-J2LqtvFYCzaj8pVYKw8klQXrLLk7TBZmQ4ShlcdkELFKGwGMfevMLneMMRkMgZxotOD9wg497LpC7O8PcvAmfw==", - "dev": true, "dependencies": { "@types/http-errors": "*", "@types/mime": "*", @@ -1918,7 +1913,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -3024,6 +3018,14 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.0.tgz", @@ -3083,6 +3085,22 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/jwks-rsa": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz", + "integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==", + "dependencies": { + "@types/express": "^4.17.20", + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/jws": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", @@ -3110,6 +3128,11 @@ "node": ">= 0.8.0" } }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, "node_modules/local-pkg": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.0.tgz", @@ -3146,6 +3169,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" + }, "node_modules/lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", @@ -3200,6 +3228,15 @@ "node": ">=10" } }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.12", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz", @@ -5636,7 +5673,6 @@ "version": "1.19.2", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", "integrity": "sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==", - "dev": true, "requires": { "@types/connect": "*", "@types/node": "*" @@ -5646,7 +5682,6 @@ "version": "3.4.35", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.35.tgz", "integrity": "sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==", - "dev": true, "requires": { "@types/node": "*" } @@ -5676,10 +5711,9 @@ "dev": true }, "@types/express": { - "version": "4.17.17", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.17.tgz", - "integrity": "sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==", - "dev": true, + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", + "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", "requires": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", @@ -5691,7 +5725,6 @@ "version": "4.17.35", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.35.tgz", "integrity": "sha512-wALWQwrgiB2AWTT91CB62b6Yt0sNHpznUXeZEcnPU3DRdlDIz74x8Qg1UUYKSVFi+va5vKOLYRBI1bRKiLLKIg==", - "dev": true, "requires": { "@types/node": "*", "@types/qs": "*", @@ -5702,8 +5735,7 @@ "@types/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-/K3ds8TRAfBvi5vfjuz8y6+GiAYBZ0x4tXv1Av6CWBWn0IlADc+ZX9pMq7oU0fNQPnBwIZl3rmeLp6SBApbxSQ==", - "dev": true + "integrity": "sha512-/K3ds8TRAfBvi5vfjuz8y6+GiAYBZ0x4tXv1Av6CWBWn0IlADc+ZX9pMq7oU0fNQPnBwIZl3rmeLp6SBApbxSQ==" }, "@types/json-schema": { "version": "7.0.12", @@ -5711,19 +5743,23 @@ "integrity": "sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==" }, "@types/jsonwebtoken": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", - "integrity": "sha512-drE6uz7QBKq1fYqqoFKTDRdFCPHd5TCub75BM+D+cMx7NU9hUz7SESLfC2fSCXVFMO5Yj8sOWHuGqPgjc+fz0Q==", - "dev": true, + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", "requires": { + "@types/ms": "*", "@types/node": "*" } }, "@types/mime": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz", - "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", - "dev": true + "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==" + }, + "@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" }, "@types/multer": { "version": "1.4.7", @@ -5737,8 +5773,7 @@ "@types/node": { "version": "20.4.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.4.0.tgz", - "integrity": "sha512-jfT7iTf/4kOQ9S7CHV9BIyRaQqHu67mOjsIQBC3BKZvzvUB6zLxEwJ6sBE3ozcvP8kF6Uk5PXN0Q+c0dfhGX0g==", - "dev": true + "integrity": "sha512-jfT7iTf/4kOQ9S7CHV9BIyRaQqHu67mOjsIQBC3BKZvzvUB6zLxEwJ6sBE3ozcvP8kF6Uk5PXN0Q+c0dfhGX0g==" }, "@types/pg": { "version": "8.10.2", @@ -5754,14 +5789,12 @@ "@types/qs": { "version": "6.9.7", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.7.tgz", - "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==", - "dev": true + "integrity": "sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==" }, "@types/range-parser": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.4.tgz", - "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==", - "dev": true + "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==" }, "@types/semver": { "version": "7.5.0", @@ -5773,7 +5806,6 @@ "version": "0.17.1", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.1.tgz", "integrity": "sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==", - "dev": true, "requires": { "@types/mime": "^1", "@types/node": "*" @@ -5783,7 +5815,6 @@ "version": "1.15.2", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.2.tgz", "integrity": "sha512-J2LqtvFYCzaj8pVYKw8klQXrLLk7TBZmQ4ShlcdkELFKGwGMfevMLneMMRkMgZxotOD9wg497LpC7O8PcvAmfw==", - "dev": true, "requires": { "@types/http-errors": "*", "@types/mime": "*", @@ -6428,7 +6459,6 @@ "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dev": true, "requires": { "ms": "2.1.2" } @@ -7244,6 +7274,11 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "dev": true }, + "jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==" + }, "js-tokens": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.0.tgz", @@ -7296,6 +7331,19 @@ "safe-buffer": "^5.0.1" } }, + "jwks-rsa": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz", + "integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==", + "requires": { + "@types/express": "^4.17.20", + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + } + }, "jws": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", @@ -7320,6 +7368,11 @@ "type-check": "~0.4.0" } }, + "limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, "local-pkg": { "version": "0.5.0", "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.0.tgz", @@ -7344,6 +7397,11 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" + }, "lodash.get": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", @@ -7395,6 +7453,15 @@ "yallist": "^4.0.0" } }, + "lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "requires": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, "magic-string": { "version": "0.30.12", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz", diff --git a/package.json b/package.json index e81b3cda..4bdd2744 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "express": "^4.18.2", "express-rate-limit": "^6.7.0", "helmet": "^7.0.0", + "jwks-rsa": "^3.2.0", "jsonwebtoken": "^9.0.1", "multer": "^1.4.5-lts.1", "nanoid": "^4.0.2", diff --git a/src/env.ts b/src/env.ts index 191ddbe2..0155e072 100644 --- a/src/env.ts +++ b/src/env.ts @@ -1,5 +1,18 @@ import type { Algorithm } from 'jsonwebtoken'; import { GroupRoleMapping } from './types/auth'; +import { StringValue } from 'ms'; + +/** + * JWT claim path configuration. + * Allows customization of where user ID, roles, and other claims are read from in the JWT. + * Defaults follow Hasura's JWT claims namespace convention. + */ +export type JwtClaimsConfig = { + namespace: string; + userId: string; + allowedRoles: string; + defaultRole: string; +}; export type Env = { ALLOWED_ROLES: string[]; @@ -16,7 +29,8 @@ export type Env = { HASURA_API_URL: string; HASURA_GRAPHQL_JWT_SECRET: string; JWT_ALGORITHMS: Algorithm[]; - JWT_EXPIRATION: string; + JWT_CLAIMS: JwtClaimsConfig; + JWT_EXPIRATION: StringValue; LOG_FILE: string; LOG_LEVEL: string; PORT: string; @@ -29,6 +43,13 @@ export type Env = { VERSION: string; }; +export const defaultJwtClaims: JwtClaimsConfig = { + namespace: 'https://hasura.io/jwt/claims', + userId: 'x-hasura-user-id', + allowedRoles: 'x-hasura-allowed-roles', + defaultRole: 'x-hasura-default-role', +}; + export const defaultEnv: Env = { AERIE_DB_HOST: 'localhost', AERIE_DB_PORT: '5432', @@ -47,8 +68,9 @@ export const defaultEnv: Env = { GQL_API_WS_URL: 'ws://localhost:8080/v1/graphql', HASURA_API_URL: 'http://hasura:8080', HASURA_GRAPHQL_JWT_SECRET: '', - JWT_ALGORITHMS: ['HS256'], - JWT_EXPIRATION: '36h', + JWT_ALGORITHMS: ['RS256'], + JWT_CLAIMS: defaultJwtClaims, + JWT_EXPIRATION: '36h' as StringValue, LOG_FILE: 'console', LOG_LEVEL: 'info', PORT: '9000', @@ -120,7 +142,13 @@ export function getEnv(): Env { const HASURA_GRAPHQL_JWT_SECRET = env['HASURA_GRAPHQL_JWT_SECRET'] ?? defaultEnv.HASURA_GRAPHQL_JWT_SECRET; const HASURA_API_URL = env['HASURA_API_URL'] ?? defaultEnv.HASURA_API_URL; const JWT_ALGORITHMS = parseArray(env['JWT_ALGORITHMS'], defaultEnv.JWT_ALGORITHMS); - const JWT_EXPIRATION = env['JWT_EXPIRATION'] ?? defaultEnv.JWT_EXPIRATION; + const JWT_CLAIMS: JwtClaimsConfig = { + namespace: env['JWT_CLAIMS_NAMESPACE'] ?? defaultJwtClaims.namespace, + userId: env['JWT_CLAIMS_USER_ID'] ?? defaultJwtClaims.userId, + allowedRoles: env['JWT_CLAIMS_ALLOWED_ROLES'] ?? defaultJwtClaims.allowedRoles, + defaultRole: env['JWT_CLAIMS_DEFAULT_ROLE'] ?? defaultJwtClaims.defaultRole, + }; + const JWT_EXPIRATION = (env['JWT_EXPIRATION'] as StringValue) ?? defaultEnv.JWT_EXPIRATION; const LOG_FILE = env['LOG_FILE'] ?? defaultEnv.LOG_FILE; const LOG_LEVEL = env['LOG_LEVEL'] ?? defaultEnv.LOG_LEVEL; const PORT = env['PORT'] ?? defaultEnv.PORT; @@ -151,6 +179,7 @@ export function getEnv(): Env { HASURA_API_URL, HASURA_GRAPHQL_JWT_SECRET, JWT_ALGORITHMS, + JWT_CLAIMS, JWT_EXPIRATION, LOG_FILE, LOG_LEVEL, diff --git a/src/main.ts b/src/main.ts index 4b6d87e8..f7693f0b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,23 +1,23 @@ +import cookieParser from 'cookie-parser'; import cors from 'cors'; import express from 'express'; import helmet from 'helmet'; import { getEnv } from './env.js'; import getLogger from './logger.js'; import initApiPlaygroundRoutes from './packages/api-playground/api-playground.js'; +import { CAMAuthAdapter } from './packages/auth/adapters/CAMAuthAdapter.js'; +import { NoAuthAdapter } from './packages/auth/adapters/NoAuthAdapter.js'; +import { validateGroupRoleMappings } from './packages/auth/functions.js'; import initAuthRoutes from './packages/auth/routes.js'; -import initExpansionRoutes from './packages/expansion/expansion.js'; import { DbMerlin } from './packages/db/db.js'; +import initExpansionRoutes from './packages/expansion/expansion.js'; +import initExternalSourceRoutes from './packages/external-source/external-source.js'; import initFileRoutes from './packages/files/files.js'; import initHasuraRoutes from './packages/hasura/hasura-events.js'; import initHealthRoutes from './packages/health/health.js'; import initPlanRoutes from './packages/plan/plan.js'; import initSwaggerRoutes from './packages/swagger/swagger.js'; -import initExternalSourceRoutes from './packages/external-source/external-source.js'; -import cookieParser from 'cookie-parser'; import { AuthAdapter } from './types/auth.js'; -import { NoAuthAdapter } from './packages/auth/adapters/NoAuthAdapter.js'; -import { CAMAuthAdapter } from './packages/auth/adapters/CAMAuthAdapter.js'; -import { validateGroupRoleMappings } from './packages/auth/functions.js'; async function main(): Promise { const logger = getLogger('main'); diff --git a/src/packages/auth/functions.ts b/src/packages/auth/functions.ts index 7b849279..53abc639 100644 --- a/src/packages/auth/functions.ts +++ b/src/packages/auth/functions.ts @@ -1,4 +1,4 @@ -import jwt, { Algorithm } from 'jsonwebtoken'; +import jwt, { Algorithm, JwtHeader, VerifyOptions } from 'jsonwebtoken'; import type { Response } from 'node-fetch'; import fetch from 'node-fetch'; import { getEnv } from '../../env.js'; @@ -14,6 +14,8 @@ import type { UserRoles, } from '../../types/auth.js'; import { loginSSO } from './adapters/CAMAuthAdapter.js'; +import { JwksClient } from 'jwks-rsa'; +import { StringValue } from 'ms'; const logger = getLogger('packages/auth/functions'); @@ -107,14 +109,86 @@ export async function syncRolesToDB(username: string, default_role: string, allo await db.query('commit;'); } -export function decodeJwt(authorizationHeader: string | undefined): JwtDecode { +function enforcePEMFormatting(publicKey: string): string { + if (publicKey.includes('-----BEGIN PUBLIC KEY-----') && publicKey.includes('-----END PUBLIC KEY-----')) { + return publicKey; + } + else { + return '-----BEGIN PUBLIC KEY-----\n' + publicKey + '\n-----END PUBLIC KEY-----' + } +} + +export async function decodeJwt(authorizationHeader: string | undefined): Promise { try { const token = authorizationHeaderToToken(authorizationHeader); const { HASURA_GRAPHQL_JWT_SECRET, JWT_ALGORITHMS } = getEnv(); - const { key }: JwtSecret = JSON.parse(HASURA_GRAPHQL_JWT_SECRET); + const { type, key, jwk_url, issuer, audience }: JwtSecret = JSON.parse(HASURA_GRAPHQL_JWT_SECRET); + const options: jwt.VerifyOptions = { algorithms: JWT_ALGORITHMS }; - const jwtPayload = jwt.verify(token, key, options) as JwtPayload; - return { jwtErrorMessage: '', jwtPayload }; + + // Add issuer/audience validation if configured (used with JWKS/OIDC) + if (issuer) { + options.issuer = issuer; + } + if (audience) { + // jwt.verify expects string or non-empty array + options.audience = Array.isArray(audience) ? audience as [string, ...string[]] : audience; + } + + type getKeyType = (header: JwtHeader, callback: any) => void; + let realKey: string | getKeyType; + + // if they are using a jwk_url instead, pull the key! + if (!key && jwk_url) { + // https://www.npmjs.com/package/jsonwebtoken + const client = new JwksClient({ + jwksUri: jwk_url + }); + + realKey = function(header, callback) { + client.getSigningKey(header.kid, function(err, key) { + if (err) { + callback(err, null); + } else if (key) { + const signingKey = key.getPublicKey(); + callback(null, signingKey); + } else { + callback(new Error('No signing key found'), null); + } + }); + } + + const verifyJwt = async function(token: string, options: VerifyOptions = {}): Promise { + return new Promise((resolve, reject) => { + jwt.verify(token, realKey, options, (err, decoded) => { + if (err) return reject(err); + resolve(decoded); + }); + }); + } + + try { + const jwtPayload = await verifyJwt(token, options); + return {jwtErrorMessage: '', jwtPayload: jwtPayload} + } catch (err) { + return {jwtErrorMessage: 'JWT verification failed: ' + err, jwtPayload: null} + } + } + else if (key) { + if (type === "RS256") { + realKey = enforcePEMFormatting(key); + } + else { + realKey = key; + } + + const jwtPayload = jwt.verify(token, realKey, options) as JwtPayload; + return { jwtErrorMessage: '', jwtPayload }; + } + else { + const jwtErrorMessage = 'Neither a valid JWT Key or JWK URL were provided. A type (algorithm) and either of those two must be provided.' + return { jwtErrorMessage, jwtPayload: null }; + } } catch (e) { console.error(e); @@ -134,22 +208,26 @@ export function generateJwt( username: string, defaultRole: string, allowedRoles: string[], - expiry: string = getEnv().JWT_EXPIRATION, + expiry: StringValue = getEnv().JWT_EXPIRATION, ): string | null { try { - const { HASURA_GRAPHQL_JWT_SECRET } = getEnv(); + const { HASURA_GRAPHQL_JWT_SECRET, JWT_CLAIMS } = getEnv(); const { key, type }: JwtSecret = JSON.parse(HASURA_GRAPHQL_JWT_SECRET); - const options: jwt.SignOptions = { algorithm: type as Algorithm, expiresIn: expiry }; - const payload: JwtPayload = { - 'https://hasura.io/jwt/claims': { - 'x-hasura-allowed-roles': allowedRoles, - 'x-hasura-default-role': defaultRole, - 'x-hasura-user-id': username, - }, - username, - }; + if (key) { + const options: jwt.SignOptions = { algorithm: type as Algorithm, expiresIn: expiry }; + const payload: JwtPayload = { + [JWT_CLAIMS.namespace]: { + [JWT_CLAIMS.allowedRoles]: allowedRoles, + [JWT_CLAIMS.defaultRole]: defaultRole, + [JWT_CLAIMS.userId]: username, + }, + username, + }; - return jwt.sign(payload, key, options); + return jwt.sign(payload, key, options); + } + console.error('using JWKS URL, so this JWT generation will not work. You also shouldn\'t be using this method if using JWKS') + return null; } catch (e) { console.error(e); return null; @@ -210,7 +288,7 @@ export async function login(username: string, password: string): Promise { - const { jwtErrorMessage, jwtPayload } = decodeJwt(authorizationHeader); + const { jwtErrorMessage, jwtPayload } = await decodeJwt(authorizationHeader); if (jwtPayload) { return { message: 'Token is valid', success: true }; diff --git a/src/packages/auth/middleware.ts b/src/packages/auth/middleware.ts index 68ca98c0..39b91a96 100644 --- a/src/packages/auth/middleware.ts +++ b/src/packages/auth/middleware.ts @@ -1,4 +1,5 @@ import type { NextFunction, Request, Response } from 'express'; +import { getEnv } from '../../env.js'; import { decodeJwt, session } from './functions.js'; export const auth = async (req: Request, res: Response, next: NextFunction) => { @@ -18,14 +19,21 @@ export const adminOnlyAuth = async (req: Request, res: Response, next: NextFunct const response = await session(authorizationHeader); if (response.success) { - const { jwtPayload } = decodeJwt(authorizationHeader); + const { jwtPayload } = await decodeJwt(authorizationHeader); if (jwtPayload == null) { res.status(401).send({ message: 'No authorization headers present.' }); return; } - const defaultRole = jwtPayload['https://hasura.io/jwt/claims']['x-hasura-default-role'] as string; - const allowedRoles = jwtPayload['https://hasura.io/jwt/claims']['x-hasura-allowed-roles'] as string[]; + const { JWT_CLAIMS } = getEnv(); + const namespace = jwtPayload[JWT_CLAIMS.namespace] as Record; + if (!namespace) { + res.status(401).send({ message: `JWT missing claims namespace: ${JWT_CLAIMS.namespace}` }); + return; + } + + const defaultRole = namespace[JWT_CLAIMS.defaultRole] as string; + const allowedRoles = namespace[JWT_CLAIMS.allowedRoles] as string[]; const { headers } = req; const { 'x-hasura-role': role } = headers; diff --git a/src/packages/hasura/hasura-events.ts b/src/packages/hasura/hasura-events.ts index 8141466a..f026db01 100644 --- a/src/packages/hasura/hasura-events.ts +++ b/src/packages/hasura/hasura-events.ts @@ -52,7 +52,7 @@ export default (app: Express) => { * - Hasura */ app.post('/modelExtraction', refreshLimiter, adminOnlyAuth, async (req, res) => { - const { jwtPayload } = decodeJwt(req.get('authorization')); + const { jwtPayload } = await decodeJwt(req.get('authorization')); const username = jwtPayload?.username as string; const { body } = req; diff --git a/src/types/auth.ts b/src/types/auth.ts index 48264e39..ed897d5f 100644 --- a/src/types/auth.ts +++ b/src/types/auth.ts @@ -7,14 +7,23 @@ export type JwtDecode = { jwtPayload: JwtPayload | null; }; +// JWT payload with configurable claims namespace +// The namespace key is dynamic (configured via JWT_CLAIMS_NAMESPACE) export type JwtPayload = { - 'https://hasura.io/jwt/claims': Record; + [namespace: string]: Record | string; username: string; }; export type JwtSecret = { - key: string; type: string; + + // either key or jwk_url + key?: string; + jwk_url?: string; + + // optional validation fields (used with JWKS/OIDC) + issuer?: string; + audience?: string | string[]; }; export type AuthResponse = { diff --git a/test/jwt.test.ts b/test/jwt.test.ts new file mode 100644 index 00000000..216afad3 --- /dev/null +++ b/test/jwt.test.ts @@ -0,0 +1,391 @@ +import { generateKeyPairSync } from 'crypto'; +import jwt from 'jsonwebtoken'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { authorizationHeaderToToken, decodeJwt } from '../src/packages/auth/functions'; + +// Generate RSA key pair for testing +const { publicKey, privateKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, +}); + +// Helper to create a valid JWT payload with Hasura claims +function createPayload(overrides = {}) { + return { + 'https://hasura.io/jwt/claims': { + 'x-hasura-allowed-roles': ['user', 'viewer'], + 'x-hasura-default-role': 'user', + 'x-hasura-user-id': 'test-user', + }, + username: 'test-user', + iss: 'https://test-issuer.example.com', + aud: 'test-audience', + ...overrides, + }; +} + +// Helper to sign a JWT with RS256 +function signToken(payload: object, options: jwt.SignOptions = {}) { + return jwt.sign(payload, privateKey, { algorithm: 'RS256', expiresIn: '1h', ...options }); +} + +describe('authorizationHeaderToToken', () => { + test('extracts token from valid Bearer header', () => { + const token = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test'; + const result = authorizationHeaderToToken(`Bearer ${token}`); + expect(result).toBe(token); + }); + + test('throws error when Bearer prefix is missing', () => { + expect(() => authorizationHeaderToToken('token-without-bearer')).toThrow( + "Authorization header does not include 'Bearer' prefix", + ); + }); + + test('throws error when header is undefined', () => { + expect(() => authorizationHeaderToToken(undefined)).toThrow('Authorization header not found'); + }); + + test('throws error when header is null', () => { + expect(() => authorizationHeaderToToken(null)).toThrow('Authorization header not found'); + }); +}); + +describe('decodeJwt with RS256 static key', () => { + beforeEach(() => { + vi.stubEnv('JWT_ALGORITHMS', JSON.stringify(['RS256'])); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + test('successfully verifies valid token with RS256 static key', async () => { + const jwtSecret = JSON.stringify({ + type: 'RS256', + key: publicKey, + }); + vi.stubEnv('HASURA_GRAPHQL_JWT_SECRET', jwtSecret); + + const payload = createPayload(); + const token = signToken(payload); + + const result = await decodeJwt(`Bearer ${token}`); + + expect(result.jwtErrorMessage).toBe(''); + expect(result.jwtPayload).not.toBeNull(); + expect(result.jwtPayload?.username).toBe('test-user'); + }); + + test('rejects token with wrong issuer when issuer validation is configured', async () => { + const jwtSecret = JSON.stringify({ + type: 'RS256', + key: publicKey, + issuer: 'https://expected-issuer.example.com', + }); + vi.stubEnv('HASURA_GRAPHQL_JWT_SECRET', jwtSecret); + + const payload = createPayload({ iss: 'https://wrong-issuer.example.com' }); + const token = signToken(payload); + + const result = await decodeJwt(`Bearer ${token}`); + + expect(result.jwtPayload).toBeNull(); + expect(result.jwtErrorMessage).toContain('jwt issuer invalid'); + }); + + test('rejects token with wrong audience when audience validation is configured', async () => { + const jwtSecret = JSON.stringify({ + type: 'RS256', + key: publicKey, + audience: 'expected-audience', + }); + vi.stubEnv('HASURA_GRAPHQL_JWT_SECRET', jwtSecret); + + const payload = createPayload({ aud: 'wrong-audience' }); + const token = signToken(payload); + + const result = await decodeJwt(`Bearer ${token}`); + + expect(result.jwtPayload).toBeNull(); + expect(result.jwtErrorMessage).toContain('jwt audience invalid'); + }); + + test('accepts token when issuer matches configured issuer', async () => { + const expectedIssuer = 'https://keycloak.example.com/realms/test'; + const jwtSecret = JSON.stringify({ + type: 'RS256', + key: publicKey, + issuer: expectedIssuer, + }); + vi.stubEnv('HASURA_GRAPHQL_JWT_SECRET', jwtSecret); + + const payload = createPayload({ iss: expectedIssuer }); + const token = signToken(payload); + + const result = await decodeJwt(`Bearer ${token}`); + + expect(result.jwtErrorMessage).toBe(''); + expect(result.jwtPayload).not.toBeNull(); + }); + + test('accepts token when audience matches configured audience', async () => { + const expectedAudience = 'aerie'; + const jwtSecret = JSON.stringify({ + type: 'RS256', + key: publicKey, + audience: expectedAudience, + }); + vi.stubEnv('HASURA_GRAPHQL_JWT_SECRET', jwtSecret); + + const payload = createPayload({ aud: expectedAudience }); + const token = signToken(payload); + + const result = await decodeJwt(`Bearer ${token}`); + + expect(result.jwtErrorMessage).toBe(''); + expect(result.jwtPayload).not.toBeNull(); + }); + + test('accepts token when audience is in array of allowed audiences', async () => { + const jwtSecret = JSON.stringify({ + type: 'RS256', + key: publicKey, + audience: ['aerie', 'other-service'], + }); + vi.stubEnv('HASURA_GRAPHQL_JWT_SECRET', jwtSecret); + + const payload = createPayload({ aud: 'aerie' }); + const token = signToken(payload); + + const result = await decodeJwt(`Bearer ${token}`); + + expect(result.jwtErrorMessage).toBe(''); + expect(result.jwtPayload).not.toBeNull(); + }); + + test('validates both issuer and audience when both are configured', async () => { + const jwtSecret = JSON.stringify({ + type: 'RS256', + key: publicKey, + issuer: 'https://keycloak.example.com', + audience: 'aerie', + }); + vi.stubEnv('HASURA_GRAPHQL_JWT_SECRET', jwtSecret); + + // Token with correct issuer but wrong audience + const payload = createPayload({ + iss: 'https://keycloak.example.com', + aud: 'wrong-audience', + }); + const token = signToken(payload); + + const result = await decodeJwt(`Bearer ${token}`); + + expect(result.jwtPayload).toBeNull(); + expect(result.jwtErrorMessage).toContain('jwt audience invalid'); + }); + + test('skips issuer validation when not configured', async () => { + const jwtSecret = JSON.stringify({ + type: 'RS256', + key: publicKey, + // no issuer configured + }); + vi.stubEnv('HASURA_GRAPHQL_JWT_SECRET', jwtSecret); + + const payload = createPayload({ iss: 'any-issuer' }); + const token = signToken(payload); + + const result = await decodeJwt(`Bearer ${token}`); + + expect(result.jwtErrorMessage).toBe(''); + expect(result.jwtPayload).not.toBeNull(); + }); + + test('skips audience validation when not configured', async () => { + const jwtSecret = JSON.stringify({ + type: 'RS256', + key: publicKey, + // no audience configured + }); + vi.stubEnv('HASURA_GRAPHQL_JWT_SECRET', jwtSecret); + + const payload = createPayload({ aud: 'any-audience' }); + const token = signToken(payload); + + const result = await decodeJwt(`Bearer ${token}`); + + expect(result.jwtErrorMessage).toBe(''); + expect(result.jwtPayload).not.toBeNull(); + }); + + test('rejects expired token', async () => { + const jwtSecret = JSON.stringify({ + type: 'RS256', + key: publicKey, + }); + vi.stubEnv('HASURA_GRAPHQL_JWT_SECRET', jwtSecret); + + const payload = createPayload(); + const token = signToken(payload, { expiresIn: '-1h' }); // Already expired + + const result = await decodeJwt(`Bearer ${token}`); + + expect(result.jwtPayload).toBeNull(); + expect(result.jwtErrorMessage).toContain('Token expired'); + }); + + test('returns error when no key or jwk_url provided', async () => { + const jwtSecret = JSON.stringify({ + type: 'RS256', + // no key or jwk_url + }); + vi.stubEnv('HASURA_GRAPHQL_JWT_SECRET', jwtSecret); + + const payload = createPayload(); + const token = signToken(payload); + + const result = await decodeJwt(`Bearer ${token}`); + + expect(result.jwtPayload).toBeNull(); + expect(result.jwtErrorMessage).toContain('Neither a valid JWT Key or JWK URL were provided'); + }); +}); + +describe('decodeJwt with HS256 static key', () => { + const hmacSecret = 'super-secret-key-that-is-long-enough-for-hs256'; + + beforeEach(() => { + vi.stubEnv('JWT_ALGORITHMS', JSON.stringify(['HS256'])); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + test('successfully verifies valid token with HS256 static key', async () => { + const jwtSecret = JSON.stringify({ + type: 'HS256', + key: hmacSecret, + }); + vi.stubEnv('HASURA_GRAPHQL_JWT_SECRET', jwtSecret); + + const payload = createPayload(); + const token = jwt.sign(payload, hmacSecret, { algorithm: 'HS256', expiresIn: '1h' }); + + const result = await decodeJwt(`Bearer ${token}`); + + expect(result.jwtErrorMessage).toBe(''); + expect(result.jwtPayload).not.toBeNull(); + expect(result.jwtPayload?.username).toBe('test-user'); + }); + + test('rejects token signed with wrong key', async () => { + const jwtSecret = JSON.stringify({ + type: 'HS256', + key: hmacSecret, + }); + vi.stubEnv('HASURA_GRAPHQL_JWT_SECRET', jwtSecret); + + const payload = createPayload(); + const token = jwt.sign(payload, 'different-secret-key-for-signing', { + algorithm: 'HS256', + expiresIn: '1h', + }); + + const result = await decodeJwt(`Bearer ${token}`); + + expect(result.jwtPayload).toBeNull(); + expect(result.jwtErrorMessage).toContain('invalid signature'); + }); +}); + +describe('configurable JWT claim paths', () => { + beforeEach(() => { + vi.stubEnv('JWT_ALGORITHMS', JSON.stringify(['RS256'])); + vi.stubEnv( + 'HASURA_GRAPHQL_JWT_SECRET', + JSON.stringify({ + type: 'RS256', + key: publicKey, + }), + ); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + test('reads claims from default Hasura namespace', async () => { + // Default namespace: https://hasura.io/jwt/claims + const payload = { + 'https://hasura.io/jwt/claims': { + 'x-hasura-allowed-roles': ['admin', 'user'], + 'x-hasura-default-role': 'admin', + 'x-hasura-user-id': 'user-123', + }, + username: 'user-123', + }; + const token = signToken(payload); + + const result = await decodeJwt(`Bearer ${token}`); + + expect(result.jwtErrorMessage).toBe(''); + expect(result.jwtPayload).not.toBeNull(); + const namespace = result.jwtPayload?.['https://hasura.io/jwt/claims'] as Record; + expect(namespace['x-hasura-user-id']).toBe('user-123'); + expect(namespace['x-hasura-allowed-roles']).toEqual(['admin', 'user']); + expect(namespace['x-hasura-default-role']).toBe('admin'); + }); + + test('reads claims from custom namespace when configured', async () => { + // Custom namespace + vi.stubEnv('JWT_CLAIMS_NAMESPACE', 'custom/claims'); + vi.stubEnv('JWT_CLAIMS_USER_ID', 'sub'); + vi.stubEnv('JWT_CLAIMS_ALLOWED_ROLES', 'roles'); + vi.stubEnv('JWT_CLAIMS_DEFAULT_ROLE', 'primary_role'); + + const payload = { + 'custom/claims': { + sub: 'custom-user-456', + roles: ['editor', 'viewer'], + primary_role: 'editor', + }, + username: 'custom-user-456', + }; + const token = signToken(payload); + + const result = await decodeJwt(`Bearer ${token}`); + + expect(result.jwtErrorMessage).toBe(''); + expect(result.jwtPayload).not.toBeNull(); + const namespace = result.jwtPayload?.['custom/claims'] as Record; + expect(namespace['sub']).toBe('custom-user-456'); + expect(namespace['roles']).toEqual(['editor', 'viewer']); + expect(namespace['primary_role']).toBe('editor'); + }); + + test('supports Keycloak-style claim paths', async () => { + // Keycloak typically uses realm_access.roles or resource_access + vi.stubEnv('JWT_CLAIMS_NAMESPACE', 'realm_access'); + vi.stubEnv('JWT_CLAIMS_ALLOWED_ROLES', 'roles'); + + const payload = { + realm_access: { + roles: ['aerie_admin', 'aerie_user'], + }, + preferred_username: 'keycloak-user', + username: 'keycloak-user', + }; + const token = signToken(payload); + + const result = await decodeJwt(`Bearer ${token}`); + + expect(result.jwtErrorMessage).toBe(''); + expect(result.jwtPayload).not.toBeNull(); + const namespace = result.jwtPayload?.['realm_access'] as Record; + expect(namespace['roles']).toEqual(['aerie_admin', 'aerie_user']); + }); +});