diff --git a/action-server/package-lock.json b/action-server/package-lock.json index 4f4dd02c59..6b6a0ff94d 100644 --- a/action-server/package-lock.json +++ b/action-server/package-lock.json @@ -13,6 +13,7 @@ "cookie": "^1.1.1", "express": "^5.0.1", "jsonwebtoken": "9.0.2", + "jwks-rsa": "3.1.0", "pg": "^8.13.3", "piscina": "4.9.2", "winston": "^3.17.0" @@ -1636,7 +1637,6 @@ "version": "1.19.5", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", - "dev": true, "license": "MIT", "dependencies": { "@types/connect": "*", @@ -1647,7 +1647,6 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -1698,7 +1697,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", - "dev": true, "license": "MIT" }, "node_modules/@types/json-schema": { @@ -1719,7 +1717,6 @@ "version": "9.0.2", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", "integrity": "sha512-drE6uz7QBKq1fYqqoFKTDRdFCPHd5TCub75BM+D+cMx7NU9hUz7SESLfC2fSCXVFMO5Yj8sOWHuGqPgjc+fz0Q==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -1736,7 +1733,6 @@ "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, "license": "MIT" }, "node_modules/@types/node": { @@ -1827,14 +1823,12 @@ "version": "6.9.18", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==", - "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/semver": { @@ -1848,7 +1842,6 @@ "version": "0.17.4", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", - "dev": true, "license": "MIT", "dependencies": { "@types/mime": "^1", @@ -1859,7 +1852,6 @@ "version": "1.15.7", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", - "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -6679,6 +6671,15 @@ "dev": true, "license": "MIT" }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6815,6 +6816,47 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/jwks-rsa": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.1.0.tgz", + "integrity": "sha512-v7nqlfezb9YfHHzYII3ef2a2j1XnGeSE/bK3WfumaYCqONAIstJbrEGapz4kadScZzEt7zYCN7bucj8C0Mv/Rg==", + "license": "MIT", + "dependencies": { + "@types/express": "^4.17.17", + "@types/jsonwebtoken": "^9.0.2", + "debug": "^4.3.4", + "jose": "^4.14.6", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jwks-rsa/node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/jwks-rsa/node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/jws": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", @@ -6877,6 +6919,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/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -6915,6 +6962,12 @@ "dev": true, "license": "MIT" }, + "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==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", @@ -7002,6 +7055,28 @@ "dev": true, "license": "ISC" }, + "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==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/lru-memoizer/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -9905,6 +9980,12 @@ "node": ">=0.4" } }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", diff --git a/action-server/package.json b/action-server/package.json index 9d8ae51b9b..376ccc417a 100644 --- a/action-server/package.json +++ b/action-server/package.json @@ -34,6 +34,7 @@ "cookie": "^1.1.1", "express": "^5.0.1", "jsonwebtoken": "9.0.2", + "jwks-rsa": "3.1.0", "pg": "^8.13.3", "piscina": "4.9.2", "winston": "^3.17.0" diff --git a/action-server/src/middleware.ts b/action-server/src/middleware.ts index be01b72a09..d7ce0dac66 100644 --- a/action-server/src/middleware.ts +++ b/action-server/src/middleware.ts @@ -33,7 +33,7 @@ export const corsMiddleware: RequestHandler = (req, res, next) => { export const authMiddleware: RequestHandler = async (req, res, next) => { const authorizationHeader = req.get('authorization'); const userRoleHeader = req.get('x-hasura-role'); - const { jwtErrorMessage, jwtPayload } = decodeJwt(authorizationHeader); + const { jwtErrorMessage, jwtPayload } = await decodeJwt(authorizationHeader); if (jwtPayload) { // token is valid // set jwt payload on `user` local, so other things can access it @@ -42,6 +42,7 @@ export const authMiddleware: RequestHandler = async (req, res, next) => { res.locals.userRole = userRoleHeader; next(); } else { - res.status(401).send({ message: `Unauthorized: ${jwtErrorMessage}`, success: false }); + // decodeJwt returns a curated message (no internals) and logs the detail server-side. + res.status(401).send({ message: jwtErrorMessage, success: false }); } }; diff --git a/action-server/src/utils/auth.ts b/action-server/src/utils/auth.ts index b724b8cf50..b3d23f9c3d 100644 --- a/action-server/src/utils/auth.ts +++ b/action-server/src/utils/auth.ts @@ -1,6 +1,7 @@ import { Request } from 'express'; import { configuration } from "../config"; -import jwt, {Algorithm} from "jsonwebtoken"; +import jwt, {Algorithm, JwtHeader} from "jsonwebtoken"; +import { JwksClient } from "jwks-rsa"; import { parseCookie } from "cookie"; export type JsonWebToken = string; @@ -16,8 +17,15 @@ export type JwtPayload = { }; export type JwtSecret = { - key: string; + // symmetric key (HMAC); absent for JWKS/OIDC + key?: string; type: string; + // IdP JWKS endpoint (asymmetric/OIDC) + jwk_url?: string; + // `iss` is the legacy alias for issuer + issuer?: string; + iss?: string; + audience?: string | string[]; }; export type AuthResponse = { @@ -95,27 +103,132 @@ export function authorizationHeaderToToken(authorizationHeader: string | undefin } } -export function decodeJwt(authorizationHeader: string | undefined): JwtDecode { +// Parsed + memoized view of HASURA_GRAPHQL_JWT_SECRET (static config, parsed once). +type VerificationConfig = { + algorithm: Algorithm; + audience?: string | string[]; + issuer?: string; + jwkUrl?: string; + key?: string; +}; + +let _config: VerificationConfig | undefined; +function getVerificationConfig(): VerificationConfig { + if (_config) { + return _config; + } + const { HASURA_GRAPHQL_JWT_SECRET } = configuration(); + const parsed: JwtSecret = JSON.parse(HASURA_GRAPHQL_JWT_SECRET); + const { key, type, jwk_url } = parsed; + // accept the legacy `iss` alias + const issuer = parsed.issuer ?? parsed.iss; + + if (!type) { + throw new Error("HASURA_GRAPHQL_JWT_SECRET must specify a 'type' field that is a valid JWT algorithm"); + } + // exactly one of key (HMAC) or jwk_url (JWKS) + if (!!key === !!jwk_url) { + throw new Error(`HASURA_GRAPHQL_JWT_SECRET must specify exactly one of 'key' or 'jwk_url' (got ${key ? 'both' : 'neither'})`); + } + // algorithm family must match the mode (HS* = symmetric key, RS*/ES*/PS* = JWKS) + const symmetric = type.startsWith('HS'); + if (symmetric && jwk_url) { + throw new Error(`HMAC algorithm '${type}' requires 'key', not 'jwk_url'`); + } + if (!symmetric && key) { + throw new Error(`Asymmetric algorithm '${type}' requires 'jwk_url', not 'key'`); + } + + _config = { algorithm: type as Algorithm, audience: parsed.audience, issuer, jwkUrl: jwk_url, key }; + return _config; +} + +// Memoized JWKS client (OIDC): cache signing keys and rate-limit fetches so unknown-`kid` floods +// can't hammer the IdP. Bounds mirror the workspace server's JwkProvider. +let _jwksClient: JwksClient | undefined; +function getJwksClient(): JwksClient { + if (!_jwksClient) { + _jwksClient = new JwksClient({ + cache: true, + cacheMaxAge: 24 * 60 * 60 * 1000, // 24h + cacheMaxEntries: 100, + jwksRequestsPerMinute: 10, + jwksUri: getVerificationConfig().jwkUrl as string, + rateLimit: true, + timeout: 30000, + }); + } + return _jwksClient; +} + +/** + * Verify a token against an IdP's JWKS (asymmetric, e.g. RS256). The signing key + * is resolved by the token's `kid` header and cached by the JWKS client. + */ +function verifyWithJwks(token: string, options: jwt.VerifyOptions): Promise { + const client = getJwksClient(); + const getKey = (header: JwtHeader, callback: (err: Error | null, key?: string) => void) => { + client.getSigningKey(header.kid, (err, signingKey) => { + if (err) { + callback(err); + } else { + callback(null, signingKey?.getPublicKey()); + } + }); + }; + return new Promise((resolve, reject) => { + jwt.verify(token, getKey, options, (err, decoded) => { + if (err) { + reject(err); + } else { + resolve(decoded as JwtPayload); + } + }); + }); +} + +export async function decodeJwt(authorizationHeader: string | undefined): Promise { try { const token = authorizationHeaderToToken(authorizationHeader); - const { HASURA_GRAPHQL_JWT_SECRET } = configuration(); - const { key, type }: JwtSecret = JSON.parse(HASURA_GRAPHQL_JWT_SECRET); - if(!type) { - throw new Error(`HASURA_GRAPHQL_JWT_SECRET must specify a 'type' field that is a valid JWT algorithm`) + const { algorithm, audience, issuer, jwkUrl, key } = getVerificationConfig(); + + const options: jwt.VerifyOptions = { algorithms: [algorithm] }; + // Optional claim validation (used with JWKS/OIDC). + if (issuer) { + options.issuer = issuer; + } + if (audience) { + options.audience = audience; } - const options: jwt.VerifyOptions = { algorithms: [type as Algorithm] }; - const jwtPayload = jwt.verify(token, key, options) as JwtPayload; + + const jwtPayload: JwtPayload = jwkUrl + ? await verifyWithJwks(token, options) + : (jwt.verify(token, key as string, options) as JwtPayload); + + // Require the Hasura claims namespace (matches the workspace server). + const hasuraClaims = jwtPayload['https://hasura.io/jwt/claims'] as Record | undefined; + if (!hasuraClaims || typeof hasuraClaims !== 'object') { + throw new Error('JWT is missing the Hasura claims namespace'); + } + + // OIDC tokens carry identity as x-hasura-user-id in the namespace, not a top-level `username`. + if (!jwtPayload.username) { + const hasuraUserId = hasuraClaims['x-hasura-user-id']; + if (hasuraUserId !== undefined && hasuraUserId !== null) { + jwtPayload.username = String(hasuraUserId); + } + } + return { jwtErrorMessage: '', jwtPayload }; - } catch (e) { - console.error(e); + } catch (error) { + console.error(error); - if (e instanceof jwt.TokenExpiredError) { - const tokenExpiredError = e as jwt.TokenExpiredError; - const jwtErrorMessage = `Token expired on ${tokenExpiredError.expiredAt}`; + if (error instanceof jwt.TokenExpiredError) { + const jwtErrorMessage = `Token expired on ${error.expiredAt.toISOString()}`; return { jwtErrorMessage, jwtPayload: null }; } else { - const error = e as Error; - const jwtErrorMessage = error?.message ?? 'Token could not be verified'; + // Curated message — don't echo raw library text (leaks expected issuer/audience). Logged above. + const jwtErrorMessage = 'Invalid authorization token'; return { jwtErrorMessage, jwtPayload: null }; } } diff --git a/action-server/tests/app.test.mts b/action-server/tests/app.test.mts index 830bb427f2..0850881235 100644 --- a/action-server/tests/app.test.mts +++ b/action-server/tests/app.test.mts @@ -39,9 +39,18 @@ test('Health check', async () => { const {key} = JSON.parse(configuration().HASURA_GRAPHQL_JWT_SECRET); +// decodeJwt now requires the Hasura claims namespace, so a valid test token must carry it. +const validClaims = { + 'https://hasura.io/jwt/claims': { + 'x-hasura-user-id': 'user-123', + 'x-hasura-default-role': 'user', + 'x-hasura-allowed-roles': ['user'], + }, +}; + test('auth middleware', async () => { await test('should allow access with valid jwt', async () => { - const validToken = jwt.sign({ sub: 'user-123' }, key, { algorithm: 'HS256', expiresIn: '1h' }); + const validToken = jwt.sign(validClaims, key, { algorithm: 'HS256', expiresIn: '1h' }); const res = await request(app) .post('/secrets') .send({action_run_id: 1, secrets: {}}) @@ -77,8 +86,36 @@ test('auth middleware', async () => { }); }); +test('username resolution', async () => { + await test('falls back to x-hasura-user-id when the top-level username claim is absent (OIDC)', async () => { + // OIDC tokens carry identity as x-hasura-user-id (not a top-level username); USERNAME must still resolve. + called.length = 0; + const claims = { + 'x-hasura-user-id': 'user-from-claims', + 'x-hasura-default-role': 'user', + 'x-hasura-allowed-roles': ['user'], + }; + const token = jwt.sign({ 'https://hasura.io/jwt/claims': claims }, key, { algorithm: 'HS256', expiresIn: '1h' }); + + const res = await request(app) + .post('/secrets') + .send({ action_run_id: 'oidc-username', secrets: {} }) + .set('Authorization', `Bearer ${token}`); + + assert.equal(res.status, 200); + assert.equal(called.length, 1); + const user = JSON.parse(called[0].secrets.user); + assert.equal(user.username, 'user-from-claims'); + }); +}); + +// Issuer (`iss` alias) and audience claim-validation are covered as unit tests: +// - action server: tests/authConfig.test.mts (issuer), which needs its own process because +// decodeJwt memoizes the parsed secret on first use. +// - workspace server: JWTServiceTest (issuer + audience ANY/none/single). + test('cookie forwarding', async () => { - const validToken = jwt.sign({ sub: 'user-123' }, key, { algorithm: 'HS256', expiresIn: '1h' }); + const validToken = jwt.sign(validClaims, key, { algorithm: 'HS256', expiresIn: '1h' }); await test('should forward configured cookies as secrets', async () => { process.env.ACTION_COOKIE_NAMES = 'ssosession,other_cookie'; diff --git a/action-server/tests/authConfig.test.mts b/action-server/tests/authConfig.test.mts new file mode 100644 index 0000000000..e39b0d87aa --- /dev/null +++ b/action-server/tests/authConfig.test.mts @@ -0,0 +1,44 @@ +import { test, mock } from "node:test"; +import assert from "assert"; +import jwt from "jsonwebtoken"; + +// Verifies the action server honors the legacy `iss` field as an issuer alias. Own file (separate +// process) because decodeJwt memoizes the parsed secret, so it must be set before the module loads. +const KEY = "test-secret-key"; +const ISSUER = "https://idp.example.com"; +const CLAIMS_NS = "https://hasura.io/jwt/claims"; + +process.env.HASURA_GRAPHQL_JWT_SECRET = JSON.stringify({ type: "HS256", key: KEY, iss: ISSUER }); + +const { decodeJwt } = await import("../src/utils/auth.ts"); + +const claims = { + [CLAIMS_NS]: { + "x-hasura-user-id": "u1", + "x-hasura-default-role": "user", + "x-hasura-allowed-roles": ["user"], + }, +}; + +test("issuer validation via the legacy `iss` field", async () => { + await test("accepts a token whose issuer matches the configured iss", async () => { + const token = jwt.sign(claims, KEY, { algorithm: "HS256", issuer: ISSUER, expiresIn: "1h" }); + + const { jwtPayload, jwtErrorMessage } = await decodeJwt(`Bearer ${token}`); + + assert.equal(jwtErrorMessage, ""); + assert.ok(jwtPayload); + assert.equal(jwtPayload.username, "u1"); + }); + + await test("rejects a token whose issuer does not match (proves iss is honored)", async () => { + // mock console.error so we don't log the confusing-but-expected verification error + const spy = mock.method(console, "error", () => {}); + const token = jwt.sign(claims, KEY, { algorithm: "HS256", issuer: "https://attacker.example.com", expiresIn: "1h" }); + + const { jwtPayload } = await decodeJwt(`Bearer ${token}`); + + spy.mock.restore(); + assert.equal(jwtPayload, null); + }); +}); diff --git a/workspace-server/build.gradle b/workspace-server/build.gradle index 7717cf9077..5c033cdb0b 100644 --- a/workspace-server/build.gradle +++ b/workspace-server/build.gradle @@ -30,6 +30,7 @@ dependencies { implementation 'org.slf4j:slf4j-simple:2.0.7' implementation 'org.glassfish:javax.json:1.1.4' implementation 'com.auth0:java-jwt:4.5.0' + implementation 'com.auth0:jwks-rsa:0.22.1' implementation 'org.postgresql:postgresql:42.6.1' implementation 'com.zaxxer:HikariCP:5.0.1' diff --git a/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/JWTService.java b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/JWTService.java index 664763abf7..1ba5dd4bb6 100644 --- a/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/JWTService.java +++ b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/JWTService.java @@ -1,13 +1,25 @@ package gov.nasa.jpl.aerie.workspace.server; +import com.auth0.jwk.JwkException; +import com.auth0.jwk.JwkProvider; +import com.auth0.jwk.JwkProviderBuilder; import com.auth0.jwt.JWT; import com.auth0.jwt.JWTVerifier; import com.auth0.jwt.algorithms.Algorithm; import com.auth0.jwt.exceptions.JWTVerificationException; import com.auth0.jwt.interfaces.DecodedJWT; +import com.auth0.jwt.interfaces.RSAKeyProvider; +import javax.json.JsonArray; import javax.json.JsonObject; +import javax.json.JsonString; +import javax.json.JsonValue; +import java.net.MalformedURLException; +import java.net.URI; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; import java.util.List; +import java.util.concurrent.TimeUnit; /** * A service for decoding JWTs. @@ -23,28 +35,114 @@ public final class JWTService { public record UserSession(String userId, String activeRole) {} JWTService(final JsonObject jwtInfo) { - final var key = jwtInfo.getString("key"); final var typeString = jwtInfo.getString("type"); - final var issuer = jwtInfo.containsKey("iss") ? jwtInfo.getString("iss") : null; - - // Expand on this switch statement as we support more Algorithm types. - // Currently, the Gateway only supports HMAC key variants - final Algorithm algorithm = switch (typeString) { - case "HS256" -> Algorithm.HMAC256(key); - case "HS384" -> Algorithm.HMAC384(key); - case "HS512" -> Algorithm.HMAC512(key); - default -> throw new IllegalArgumentException("Unsupported JWT algorithm: " + typeString); - }; + // `issuer` is canonical; `iss` is the legacy alias. Prefer `issuer` when both are present. + final var issuer = jwtInfo.containsKey("issuer") ? jwtInfo.getString("issuer") + : jwtInfo.containsKey("iss") ? jwtInfo.getString("iss") : null; + final var audiences = parseAudience(jwtInfo); + final var jwkUrl = jwtInfo.containsKey("jwk_url") ? jwtInfo.getString("jwk_url") : null; + + final Algorithm algorithm; + if (jwkUrl != null && !jwkUrl.isBlank()) { + // OIDC: verify against the IdP's JWKS (key resolved per-token by `kid`). + final var keyProvider = buildJwksKeyProvider(jwkUrl); + algorithm = switch (typeString) { + case "RS256" -> Algorithm.RSA256(keyProvider); + case "RS384" -> Algorithm.RSA384(keyProvider); + case "RS512" -> Algorithm.RSA512(keyProvider); + default -> throw new IllegalArgumentException("Unsupported JWKS/asymmetric algorithm: " + typeString); + }; + } else { + // JWT/SSO: verify against the shared symmetric (HMAC) key. + final var key = jwtInfo.getString("key"); + algorithm = switch (typeString) { + case "HS256" -> Algorithm.HMAC256(key); + case "HS384" -> Algorithm.HMAC384(key); + case "HS512" -> Algorithm.HMAC512(key); + default -> throw new IllegalArgumentException("Unsupported JWT algorithm: " + typeString); + }; + } final var vbuilder = JWT.require(algorithm); // add any specific claim validations if(issuer != null && !issuer.isBlank()) { vbuilder.withIssuer(issuer); } + if(audiences != null && audiences.length > 0) { + // withAnyOfAudience matches ANY (java-jwt's withAudience requires ALL; the jsonwebtoken side matches any). + vbuilder.withAnyOfAudience(audiences); + } verifier = vbuilder.build(); } + /** + * Read the optional `audience` claim-validation config. Per the JWT spec, `aud` may be a + * single string or an array of strings, so accept either (the gateway/jsonwebtoken side + * does too). Returns null when unset. + */ + private static String[] parseAudience(final JsonObject jwtInfo) { + if (!jwtInfo.containsKey("audience")) { + return null; + } + final var value = jwtInfo.get("audience"); + return switch (value.getValueType()) { + case STRING -> new String[] { ((JsonString) value).getString() }; + case ARRAY -> ((JsonArray) value).stream().map(element -> { + // explicit element check -> clear error instead of a raw ClassCastException + if (element.getValueType() != JsonValue.ValueType.STRING) { + throw new IllegalArgumentException("OIDC 'audience' array must contain only strings"); + } + return ((JsonString) element).getString(); + }).toArray(String[]::new); + default -> throw new IllegalArgumentException("OIDC 'audience' must be a string or an array of strings"); + }; + } + + /** + * Build an RSA key provider backed by a remote JWKS endpoint, with bounded caching + * and rate limiting so signing keys aren't refetched on every verification. + */ + private static RSAKeyProvider buildJwksKeyProvider(final String jwkUrl) { + final JwkProvider provider; + try { + provider = new JwkProviderBuilder(URI.create(jwkUrl).toURL()) + // Cache well above any realistic active-key count (rotation overlap / multi-realm) so + // legitimate keys aren't evicted; rate-limit fetches so unknown-`kid` floods can't hammer + // the IdP. A `kid` maps to one immutable key, so long caching never goes stale. + .cached(100, 24, TimeUnit.HOURS) + .rateLimited(10, 1, TimeUnit.MINUTES) + .build(); + } catch (final MalformedURLException | IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid jwk_url for JWT verification: " + jwkUrl, e); + } + return new RSAKeyProvider() { + @Override + public RSAPublicKey getPublicKeyById(final String keyId) { + try { + // Guard the cast: a non-RSA key (e.g. EC) would otherwise throw an uncaught ClassCastException (500, not 401). + final var publicKey = provider.get(keyId).getPublicKey(); + if (publicKey instanceof RSAPublicKey rsaPublicKey) { + return rsaPublicKey; + } + throw new JWTVerificationException("JWKS signing key '" + keyId + "' is not an RSA key, cannot verify with an RSA algorithm."); + } catch (final JwkException e) { + throw new JWTVerificationException("Unable to fetch JWKS signing key '" + keyId + "': " + e.getMessage(), e); + } + } + + @Override + public RSAPrivateKey getPrivateKey() { + return null; + } + + @Override + public String getPrivateKeyId() { + return null; + } + }; + } + /** * Decode a JWT authorization header into a validated UserSession * @param authHeader the contents of the Authorization header @@ -60,16 +158,29 @@ public UserSession validateAuthorization(String authHeader, String activeRole) t final DecodedJWT decodedJWT; decodedJWT = verifier.verify(token); - final var username = decodedJWT.getClaim("username").asString(); final var hasuraClaims = decodedJWT.getClaim("https://hasura.io/jwt/claims").asMap(); - if (username == null || username.isBlank()) { - throw new JWTVerificationException("Missing or invalid username in JWT."); - } if (hasuraClaims == null) { throw new JWTVerificationException("Missing hasura claims in JWT."); } + // Resolve the user's identity. Gateway-minted tokens (JWT/SSO modes) carry a top-level + // `username` claim; OIDC tokens (e.g. Keycloak) instead carry it as `x-hasura-user-id` + // within the Hasura claims namespace. Accept either, mirroring how the gateway derives + // identity (see aerie-gateway session(): namespace[x-hasura-user-id]). + var username = decodedJWT.getClaim("username").asString(); + if (username == null || username.isBlank()) { + // Coerce defensively — Hasura requires string session vars, but an IdP could emit a non-string. + final var hasuraUserId = hasuraClaims.get("x-hasura-user-id"); + if (hasuraUserId != null) { + username = String.valueOf(hasuraUserId); + } + } + + if (username == null || username.isBlank()) { + throw new JWTVerificationException("Missing or invalid username in JWT."); + } + // Validate the active role, if present if(activeRole != null && !activeRole.isBlank()) { // Confirmed via runtime inspection that this String Array in the token is stored as an ArrayList in the Map diff --git a/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceBindings.java b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceBindings.java index 583006163a..0a86db5e4b 100644 --- a/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceBindings.java +++ b/workspace-server/src/main/java/gov/nasa/jpl/aerie/workspace/server/WorkspaceBindings.java @@ -1,6 +1,7 @@ package gov.nasa.jpl.aerie.workspace.server; import com.auth0.jwt.exceptions.JWTVerificationException; +import com.auth0.jwt.exceptions.TokenExpiredException; import gov.nasa.jpl.aerie.permissions.PermissionsService; import gov.nasa.jpl.aerie.permissions.WorkspaceAction; import gov.nasa.jpl.aerie.permissions.exceptions.Forbidden; @@ -209,8 +210,13 @@ private JWTService.UserSession authorize(Context context) { } else { try { return jwtService.validateAuthorization(authHeader, activeRole); + } catch (TokenExpiredException tee) { + logger.warn("JWT expired: {}", tee.getMessage()); + throw new UnauthorizedResponse("Authorization token expired"); } catch (JWTVerificationException jve) { - throw new UnauthorizedResponse(jve.getMessage()); + // curated message; detail logged above (don't leak JWKS/library internals) + logger.warn("JWT verification failed: {}", jve.getMessage()); + throw new UnauthorizedResponse("Invalid authorization token"); } } } diff --git a/workspace-server/src/test/java/gov/nasa/jpl/aerie/workspace/server/JWTServiceTest.java b/workspace-server/src/test/java/gov/nasa/jpl/aerie/workspace/server/JWTServiceTest.java new file mode 100644 index 0000000000..c4892d09a3 --- /dev/null +++ b/workspace-server/src/test/java/gov/nasa/jpl/aerie/workspace/server/JWTServiceTest.java @@ -0,0 +1,225 @@ +package gov.nasa.jpl.aerie.workspace.server; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.JWTCreator; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.JWTVerificationException; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import javax.json.Json; +import javax.json.JsonObject; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Unit tests for {@link JWTService}. These exercise the offline (HMAC) verification path, which is + * enough to cover identity resolution and claim validation; the JWKS/asymmetric path shares the same + * downstream logic but requires a live key provider, so it is out of scope here. + */ +class JWTServiceTest { + + private static final String KEY = "test-secret-key-test-secret-key"; + private static final String CLAIMS_NS = "https://hasura.io/jwt/claims"; + + // ---- config builders ---- + + private static JsonObject hmacConfig() { + return Json.createObjectBuilder().add("type", "HS256").add("key", KEY).build(); + } + + private static JsonObject hmacConfigWithIssuer(final String iss) { + return Json.createObjectBuilder().add("type", "HS256").add("key", KEY).add("iss", iss).build(); + } + + private static JsonObject hmacConfigWithAudiences(final String... audiences) { + final var arr = Json.createArrayBuilder(); + for (final var a : audiences) { + arr.add(a); + } + return Json.createObjectBuilder().add("type", "HS256").add("key", KEY).add("audience", arr).build(); + } + + private static JsonObject hmacConfigWithSingleAudience(final String audience) { + return Json.createObjectBuilder().add("type", "HS256").add("key", KEY).add("audience", audience).build(); + } + + // ---- token builders ---- + + private static Map hasuraClaims(final String userId, final String defaultRole, final List allowedRoles) { + final var claims = new HashMap(); + if (userId != null) { + claims.put("x-hasura-user-id", userId); + } + if (defaultRole != null) { + claims.put("x-hasura-default-role", defaultRole); + } + if (allowedRoles != null) { + claims.put("x-hasura-allowed-roles", allowedRoles); + } + return claims; + } + + private static String token(final String username, final Map claims) { + return token(username, claims, builder -> {}); + } + + private static String token(final String username, final Map claims, final Consumer customize) { + final var builder = JWT.create(); + if (username != null) { + builder.withClaim("username", username); + } + if (claims != null) { + builder.withClaim(CLAIMS_NS, claims); + } + customize.accept(builder); + return builder.sign(Algorithm.HMAC256(KEY)); + } + + private static String bearer(final String token) { + return "Bearer " + token; + } + + @Nested + class IdentityResolution { + + @Test + void resolvesTopLevelUsernameClaim() { + final var service = new JWTService(hmacConfig()); + final var jwt = token("alice", hasuraClaims("ignored-id", "user", List.of("user"))); + + final var session = service.validateAuthorization(bearer(jwt), null); + + assertEquals("alice", session.userId()); + assertEquals("user", session.activeRole()); + } + + @Test + void fallsBackToHasuraUserIdWhenUsernameAbsent() { + // OIDC tokens (e.g. Keycloak) carry identity as x-hasura-user-id inside the claims namespace + // rather than as a top-level `username` claim. + final var service = new JWTService(hmacConfig()); + final var jwt = token(null, hasuraClaims("keycloak-sub-123", "user", List.of("user"))); + + final var session = service.validateAuthorization(bearer(jwt), null); + + assertEquals("keycloak-sub-123", session.userId()); + } + + @Test + void throwsWhenNeitherUsernameNorHasuraUserIdPresent() { + final var service = new JWTService(hmacConfig()); + final var jwt = token(null, hasuraClaims(null, "user", List.of("user"))); + + assertThrows(JWTVerificationException.class, () -> service.validateAuthorization(bearer(jwt), null)); + } + + @Test + void throwsWhenHasuraClaimsNamespaceMissing() { + final var service = new JWTService(hmacConfig()); + final var jwt = token("alice", null); + + assertThrows(JWTVerificationException.class, () -> service.validateAuthorization(bearer(jwt), null)); + } + } + + @Nested + class RoleValidation { + + @Test + void acceptsActiveRoleWhenInAllowedRoles() { + final var service = new JWTService(hmacConfig()); + final var jwt = token("alice", hasuraClaims("alice", "user", List.of("user", "admin"))); + + final var session = service.validateAuthorization(bearer(jwt), "admin"); + + assertEquals("admin", session.activeRole()); + } + + @Test + void rejectsActiveRoleNotInAllowedRoles() { + final var service = new JWTService(hmacConfig()); + final var jwt = token("alice", hasuraClaims("alice", "user", List.of("user"))); + + assertThrows(JWTVerificationException.class, () -> service.validateAuthorization(bearer(jwt), "admin")); + } + + @Test + void fallsBackToDefaultRoleWhenNoActiveRole() { + final var service = new JWTService(hmacConfig()); + final var jwt = token("alice", hasuraClaims("alice", "viewer", List.of("viewer"))); + + final var session = service.validateAuthorization(bearer(jwt), null); + + assertEquals("viewer", session.activeRole()); + } + + @Test + void throwsWhenNoActiveRoleAndNoDefaultRole() { + final var service = new JWTService(hmacConfig()); + final var jwt = token("alice", hasuraClaims("alice", null, null)); + + assertThrows(JWTVerificationException.class, () -> service.validateAuthorization(bearer(jwt), null)); + } + } + + @Nested + class IssuerValidation { + + @Test + void acceptsMatchingIssuer() { + final var service = new JWTService(hmacConfigWithIssuer("https://idp.example.com")); + final var jwt = token("alice", hasuraClaims("alice", "user", List.of("user")), + b -> b.withIssuer("https://idp.example.com")); + + assertEquals("alice", service.validateAuthorization(bearer(jwt), null).userId()); + } + + @Test + void rejectsMismatchedIssuer() { + final var service = new JWTService(hmacConfigWithIssuer("https://idp.example.com")); + final var jwt = token("alice", hasuraClaims("alice", "user", List.of("user")), + b -> b.withIssuer("https://attacker.example.com")); + + assertThrows(JWTVerificationException.class, () -> service.validateAuthorization(bearer(jwt), null)); + } + } + + @Nested + class AudienceValidation { + + @Test + void acceptsTokenMatchingAnyConfiguredAudience() { + // Configured with two audiences; a token carrying only one of them must be accepted (ANY-of + // semantics), matching the action-server/gateway (jsonwebtoken) behavior for an audience array. + final var service = new JWTService(hmacConfigWithAudiences("aerie", "workspace")); + final var jwt = token("alice", hasuraClaims("alice", "user", List.of("user")), + b -> b.withAudience("aerie")); + + assertEquals("alice", service.validateAuthorization(bearer(jwt), null).userId()); + } + + @Test + void rejectsTokenMatchingNoConfiguredAudience() { + final var service = new JWTService(hmacConfigWithAudiences("aerie", "workspace")); + final var jwt = token("alice", hasuraClaims("alice", "user", List.of("user")), + b -> b.withAudience("someone-else")); + + assertThrows(JWTVerificationException.class, () -> service.validateAuthorization(bearer(jwt), null)); + } + + @Test + void acceptsTokenForSingleConfiguredAudience() { + final var service = new JWTService(hmacConfigWithSingleAudience("aerie")); + final var jwt = token("alice", hasuraClaims("alice", "user", List.of("user")), + b -> b.withAudience("aerie")); + + assertEquals("alice", service.validateAuthorization(bearer(jwt), null).userId()); + } + } +}