From 79501408a0856d77878ff967b321edc69123d3a0 Mon Sep 17 00:00:00 2001 From: Vladimir Ignatov Date: Tue, 15 Jun 2021 12:10:21 -0400 Subject: [PATCH] Improved error handling #51 --- src/AuthorizeHandler.js | 35 ++-- src/OperationOutcome.js | 52 +++++ src/RegistrationHandler.js | 97 ++++----- src/ScopeSet.js | 7 - src/TokenHandler.js | 305 +++++++++++++-------------- src/config.js | 35 ---- src/errors.js | 199 ++++++++++++++++++ src/fhir-error.js | 27 --- src/fhir-server.js | 18 +- src/index.js | 9 +- src/launcher.js | 2 +- src/lib.js | 370 ++++++++++++++------------------- src/middlewares.js | 29 ++- src/patient-compartment.js | 195 ------------------ src/reverse-proxy.js | 159 --------------- src/simple-proxy.js | 192 +++++++++++++---- test/api.js | 407 ++++++++++++++++++++++++++++--------- test/unit.js | 55 +++++ 18 files changed, 1143 insertions(+), 1050 deletions(-) create mode 100644 src/OperationOutcome.js create mode 100644 src/errors.js delete mode 100644 src/fhir-error.js delete mode 100644 src/patient-compartment.js delete mode 100644 src/reverse-proxy.js create mode 100644 test/unit.js diff --git a/src/AuthorizeHandler.js b/src/AuthorizeHandler.js index 93650e90..35259b9b 100644 --- a/src/AuthorizeHandler.js +++ b/src/AuthorizeHandler.js @@ -2,11 +2,14 @@ const jwt = require("jsonwebtoken"); const base64url = require("base64-url"); const Url = require("url"); +const util = require("util") const ScopeSet = require("./ScopeSet"); const config = require("./config"); const Codec = require("../static/codec.js"); const Lib = require("./lib"); const SMARTHandler = require("./SMARTHandler"); +const errors = require("./errors"); + class AuthorizeHandler extends SMARTHandler { @@ -253,13 +256,10 @@ class AuthorizeHandler extends SMARTHandler { const missingParam = Lib.getFirstMissingProperty(req.query, requiredParams); if (missingParam) { - if (missingParam == "redirect_uri") { - Lib.replyWithError(res, "missing_parameter", 400, missingParam); - } - else { - Lib.redirectWithError(req, res, "missing_parameter", missingParam); - } - return false; + + // If redirect_uri is the missing param reply with OAuth. + // Otherwise redirect and pass error params to the redirect uri. + throw new Lib.OAuthError(missingParam == "redirect_uri" ? 400 : 302, util.format("Missing %s parameter", missingParam), "invalid_request") } // bad_redirect_uri if we cannot parse it @@ -267,15 +267,13 @@ class AuthorizeHandler extends SMARTHandler { try { RedirectURL = Url.parse(decodeURIComponent(req.query.redirect_uri), true); } catch (ex) { - Lib.replyWithError(res, "bad_redirect_uri", 400, ex.message); - return false; + throw Lib.OAuthError.from(errors.authorization_code.bad_redirect_uri, ex.message) } // Relative redirect_uri like "whatever" will eventually result in wrong // URLs like "/auth/whatever". We must only support full URLs. if (!RedirectURL.protocol) { - Lib.replyWithError(res, "no_redirect_uri_protocol", 400, req.query.redirect_uri); - return false; + throw Lib.OAuthError.from(errors.authorization_code.no_redirect_uri_protocol, req.query.redirect_uri) } // The "aud" param must match the apiUrl (but can have different protocol) @@ -284,8 +282,7 @@ class AuthorizeHandler extends SMARTHandler { let a = Lib.normalizeUrl(req.query.aud).replace(/^https?/, "").replace(/^:\/\/localhost/, "://127.0.0.1"); let b = Lib.normalizeUrl(apiUrl ).replace(/^https?/, "").replace(/^:\/\/localhost/, "://127.0.0.1"); if (a != b) { - Lib.redirectWithError(req, res, "bad_audience"); - return false; + throw new Lib.OAuthError(302, "Bad audience value", "invalid_request") } sim.aud_validated = "1"; } @@ -309,28 +306,26 @@ class AuthorizeHandler extends SMARTHandler { // User decided not to authorize the app launch if (req.query.auth_success == "0") { - return Lib.redirectWithError(req, res, "unauthorized"); + throw Lib.OAuthError.from(errors.authorization_code.unauthorized) } // Simulate auth_invalid_client_id error if requested if (sim.auth_error == "auth_invalid_client_id") { - return Lib.redirectWithError(req, res, "sim_invalid_client_id"); + throw Lib.OAuthError.from(errors.authorization_code.sim_invalid_client_id) } // Simulate auth_invalid_redirect_uri error if requested if (sim.auth_error == "auth_invalid_redirect_uri") { - return Lib.redirectWithError(req, res, "sim_invalid_redirect_uri"); + throw Lib.OAuthError.from(errors.authorization_code.sim_invalid_redirect_uri) } // Simulate auth_invalid_scope error if requested if (sim.auth_error == "auth_invalid_scope") { - return Lib.redirectWithError(req, res, "sim_invalid_scope"); + throw Lib.OAuthError.from(errors.authorization_code.sim_invalid_scope) } // Validate query parameters - if (!this.validateParams()) { - return; - } + this.validateParams(); // PATIENT LOGIN SCREEN if (this.needToLoginAsPatient()) { diff --git a/src/OperationOutcome.js b/src/OperationOutcome.js new file mode 100644 index 00000000..1e00e075 --- /dev/null +++ b/src/OperationOutcome.js @@ -0,0 +1,52 @@ + +const RE_GT = />/g; +const RE_LT = /

Operation Outcome

' + + '' + + '
ERROR[]
' + htmlEncode(this.message) + '
' + }, + "issue": [ + { + "severity" : this.severity, + "code" : this.issueCode, + "diagnostics": this.message + } + ] + } + } +} + +module.exports = OperationOutcome \ No newline at end of file diff --git a/src/RegistrationHandler.js b/src/RegistrationHandler.js index bfc4adce..14de3e69 100644 --- a/src/RegistrationHandler.js +++ b/src/RegistrationHandler.js @@ -1,67 +1,40 @@ -const jwt = require("jsonwebtoken"); -const config = require("./config"); -const SMARTHandler = require("./SMARTHandler"); -const Lib = require("./lib"); - -class RegistrationHandler extends SMARTHandler { - - static handleRequest(req, res) { - return new RegistrationHandler(req, res).handle(); +const jwt = require("jsonwebtoken") +const config = require("./config") +const errors = require("./errors") + +/** @type any */ +const assert = require("./lib").assert + +module.exports = function handleRegistration(req, res) { + + // Require "application/x-www-form-urlencoded" POSTs + assert(req.is("application/x-www-form-urlencoded"), errors.form_content_type_required) + + // parse and validate the "iss" parameter + let iss = String(req.body.iss || "").trim() + assert(iss, errors.registration.missing_param, "iss") + + // parse and validate the "pub_key" parameter + let publicKey = String(req.body.pub_key || "").trim() + assert(publicKey, errors.registration.missing_param, "pub_key") + + // parse and validate the "dur" parameter + let dur = parseInt(req.body.dur || "15", 10) + assert(!isNaN(dur) && isFinite(dur) && dur >= 0, errors.registration.invalid_param, "dur") + + // Build the result token + let jwtToken = { pub_key: publicKey, iss } + + // Note that if dur is 0 accessTokensExpireIn will not be included + if (dur) { + jwtToken.accessTokensExpireIn = dur } - handle() { - const req = this.request; - const res = this.response; - - // Require "application/x-www-form-urlencoded" POSTs - if (!req.headers["content-type"] || req.headers["content-type"].indexOf("application/x-www-form-urlencoded") !== 0) { - return Lib.replyWithError(res, "form_content_type_required", 401); - } - - this.handleBackendServiceRegistration(); + // Custom errors (if any) + if (req.body.auth_error) { + jwtToken.auth_error = req.body.auth_error } - handleBackendServiceRegistration() { - const req = this.request; - const res = this.response; - - // parse and validate the "iss" parameter - let iss = String(req.body.iss || "").trim(); - if (!iss) { - return Lib.replyWithError(res, "missing_parameter", 400, "iss"); - } - - // parse and validate the "pub_key" parameter - let publicKey = String(req.body.pub_key || "").trim(); - if (!publicKey) { - return Lib.replyWithError(res, "missing_parameter", 400, "pub_key"); - } - - // parse and validate the "dur" parameter - let dur = parseInt(req.body.dur || "15", 10); - if (isNaN(dur) || !isFinite(dur) || dur < 0) { - return Lib.replyWithError(res, "invalid_parameter", 400, "dur"); - } - - // Build the result token - let jwtToken = { - pub_key: publicKey, - iss - }; - - // Note that if dur is 0 accessTokensExpireIn will not be included - if (dur) { - jwtToken.accessTokensExpireIn = dur; - } - - // Custom errors (if any) - if (req.body.auth_error) { - jwtToken.auth_error = req.body.auth_error; - } - - // Reply with signed token as text - res.type("text").send(jwt.sign(jwtToken, config.jwtSecret)); - } + // Reply with signed token as text + res.type("text").send(jwt.sign(jwtToken, config.jwtSecret)) } - -module.exports = RegistrationHandler; diff --git a/src/ScopeSet.js b/src/ScopeSet.js index 8bbdc8d0..b53e1055 100644 --- a/src/ScopeSet.js +++ b/src/ScopeSet.js @@ -1,5 +1,3 @@ -const config = require("./config"); - /** * This class tries to make it easier and cleaner to work with scopes (mostly by * using the two major methods - "has" and "matches"). @@ -88,11 +86,6 @@ class ScopeSet */ static getInvalidSystemScopes(scopes) { scopes = String(scopes || "").trim(); - - if (!scopes) { - return config.errors.missing_scope; - } - return scopes.split(/\s+/).find(s => !( /^system\/(\*|[A-Z][a-zA-Z]+)(\.(read|write|\*))?$/.test(s) )) || ""; diff --git a/src/TokenHandler.js b/src/TokenHandler.js index ffc45f1d..4ed7e128 100644 --- a/src/TokenHandler.js +++ b/src/TokenHandler.js @@ -5,6 +5,10 @@ const config = require("./config"); const SMARTHandler = require("./SMARTHandler"); const Lib = require("./lib"); const ScopeSet = require("./ScopeSet"); +const errors = require("./errors") + +/** @type {typeof Lib.assert} */ +const assert = require("./lib").assert; // Generate this PEM cert once when the server starts and use it later to sign @@ -29,13 +33,9 @@ class TokenHandler extends SMARTHandler { */ handle() { const req = this.request; - const res = this.response; // Require "application/x-www-form-urlencoded" POSTs - let ct = req.headers["content-type"] || ""; - if (ct.indexOf("application/x-www-form-urlencoded") !== 0) { - return Lib.replyWithError(res, "form_content_type_required", 401); - } + assert(req.is("application/x-www-form-urlencoded"), errors.form_content_type_required); switch (req.body.grant_type) { case "client_credentials": @@ -44,9 +44,9 @@ class TokenHandler extends SMARTHandler { return this.handleAuthorizationCode(); case "refresh_token": return this.handleRefreshToken(); + default: + assert.fail(errors.bad_grant_type, req.body.grant_type); } - - Lib.replyWithError(res, "bad_grant_type", 400); } /** @@ -55,75 +55,68 @@ class TokenHandler extends SMARTHandler { * details token. */ handleBackendService() { - const req = this.request; - const res = this.response; + const { + originalUrl, + body: { + client_assertion_type, + client_assertion, + scope + } + } = this.request; + + const { baseUrl, jwtSecret } = config; + + /** @type {any[]} */ + const algorithms = ["RS256", "RS384", "RS512", "ES256", "ES384", "ES512"]; + + const aud = baseUrl + originalUrl; + + /** @type {any} */ + let authenticationToken = {}; + + /** @type {any} */ + let clientDetailsToken = {}; + + let scopeError = ""; // client_assertion_type is required - if (!req.body.client_assertion_type) { - return Lib.replyWithError(res, "missing_client_assertion_type", 401); - } + assert(client_assertion_type, errors.client_credentials.missing_client_assertion_type); // client_assertion_type must have a fixed value - if (req.body.client_assertion_type != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer") { - return Lib.replyWithError(res, "invalid_client_assertion_type", 401); - } + assert(client_assertion_type == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", errors.client_credentials.invalid_client_assertion_type) + + // client_assertion must be a sent + assert(client_assertion, errors.client_credentials.missing_registration_token); // client_assertion must be a token - let authenticationToken; - try { - authenticationToken = Lib.parseToken(req.body.client_assertion); - } catch (ex) { - return Lib.replyWithError(res, "invalid_registration_token", 401, ex.message); - } + assert(() => authenticationToken = jwt.decode(client_assertion), errors.client_credentials.invalid_registration_token); - // The client_id must be a token - let clientDetailsToken; - try { - clientDetailsToken = Lib.parseToken(authenticationToken.sub); - } catch (ex) { - return Lib.replyWithError(res, "invalid_client_details_token", 401, ex.message); - } + // client_assertion must be a parsed + assert(authenticationToken, errors.client_credentials.invalid_registration_token); + + // The client_id must be valid token + assert(() => clientDetailsToken = jwt.verify(authenticationToken.sub, jwtSecret), errors.client_credentials.invalid_client_details_token); // simulate expired_registration_token error - if (clientDetailsToken.auth_error == "token_expired_registration_token") { - return Lib.replyWithError(res, "token_expired_registration_token", 401); - } + assert(clientDetailsToken.auth_error != "token_expired_registration_token", errors.client_credentials.token_expired_registration_token); // Validate authenticationToken.aud (must equal this url) - let tokenUrl = config.baseUrl + req.originalUrl; - if (tokenUrl.replace(/^https?/, "") !== authenticationToken.aud.replace(/^https?/, "")) { - return Lib.replyWithError(res, "invalid_aud", 401, tokenUrl); - } + assert(aud.replace(/^https?/, "") == authenticationToken.aud.replace(/^https?/, ""), errors.client_credentials.invalid_aud, aud); - // Validate authenticationToken.iss (must equal whatever the user entered at - // registration time, i.e. clientDetailsToken.iss) - if (authenticationToken.iss !== clientDetailsToken.iss) { - return Lib.replyWithError(res, "invalid_token_iss", 401, authenticationToken.iss, clientDetailsToken.iss); - } + // authenticationToken.iss must equal whatever the user entered at registration time, i.e. clientDetailsToken.iss) + assert(authenticationToken.iss == clientDetailsToken.iss, errors.client_credentials.invalid_token_iss, authenticationToken.iss, clientDetailsToken.iss); // simulated invalid_jti error - if (clientDetailsToken.auth_error == "invalid_jti") { - return Lib.replyWithError(res, "invalid_jti", 401); - } + assert(clientDetailsToken.auth_error != "invalid_jti", errors.client_credentials.invalid_jti); // Validate scope - let tokenError = ScopeSet.getInvalidSystemScopes(req.body.scope); - if (tokenError) { - return Lib.replyWithError(res, "invalid_scope", 401, tokenError); - } + assert(!(scopeError = ScopeSet.getInvalidSystemScopes(scope)), errors.client_credentials.invalid_scope, scopeError); // simulated token_invalid_scope - if (clientDetailsToken.auth_error == "token_invalid_scope") { - return Lib.replyWithError(res, "token_invalid_scope", 401); - } + assert(clientDetailsToken.auth_error != "token_invalid_scope", errors.client_credentials.simulated_invalid_scope); - try { - jwt.verify(req.body.client_assertion, clientDetailsToken.pub_key, { - algorithms: [ "RS256", "RS384", "RS512", "ES256", "ES384", "ES512" ] - }); - } catch (e) { - return Lib.replyWithError(res, "invalid_token", 401, e.message); - } + // Verify the client_assertion token signature + assert(() => jwt.verify(client_assertion, clientDetailsToken.pub_key, { algorithms }), errors.client_credentials.invalid_token); return this.finish(clientDetailsToken); } @@ -134,11 +127,7 @@ class TokenHandler extends SMARTHandler { */ handleAuthorizationCode() { let token; - try { - token = jwt.verify(this.request.body.code, config.jwtSecret); - } catch (e) { - return Lib.replyWithError(this.response, "invalid_token", 401, e.message); - } + assert(() => token = jwt.verify(this.request.body.code, config.jwtSecret), errors.authorization_code.invalid_code); return this.finish(token); } @@ -148,17 +137,10 @@ class TokenHandler extends SMARTHandler { * with it. */ handleRefreshToken() { + /** @type {any} */ let token; - try { - token = jwt.verify(this.request.body.refresh_token, config.jwtSecret); - } catch (e) { - return Lib.replyWithError(this.response, "invalid_token", 401, e.message); - } - - if (token.auth_error == "token_expired_refresh_token") { - return Lib.replyWithError(this.response, "sim_expired_refresh_token", 401); - } - + assert(() => token = jwt.verify(this.request.body.refresh_token, config.jwtSecret), errors.refresh_token.invalid_refresh_token); + assert(token.auth_error != "token_expired_refresh_token", errors.refresh_token.expired_refresh_token); return this.finish(token); } @@ -166,43 +148,41 @@ class TokenHandler extends SMARTHandler { * Validates authorization header and/or triggers custom authorization * errors for confidential clients */ - validateAuth(clientDetailsToken) { + validateBasicAuth(clientDetailsToken) { const authHeader = this.request.headers.authorization; - if (authHeader && authHeader.search(/^basic\s*/i) === 0) { - const req = this.request; - const res = this.response; - - // Simulate invalid client secret error - if (req.body.auth_error == "auth_invalid_client_secret" || - clientDetailsToken.auth_error == "auth_invalid_client_secret") { - Lib.replyWithError(res, "sim_invalid_client_secret", 401); - return false; - } + + if (!authHeader || authHeader.search(/^basic\s*/i) !== 0) { + return; + } - let auth = authHeader.replace(/^basic\s*/i, ""); - - // Check for empty auth - if (!auth) { - Lib.replyWithError(res, "empty_auth_header", 401, authHeader); - return false; - } + const req = this.request; - // Check for invalid base64 - try { - auth = new Buffer(auth, "base64").toString().split(":"); - } catch (err) { - Lib.replyWithError(res, "bad_auth_header", 401, authHeader, err.message); - return false; - } + // Simulate invalid client secret error + assert( + req.body.auth_error != "auth_invalid_client_secret" && + clientDetailsToken.auth_error != "auth_invalid_client_secret", + errors.client_credentials.simulated_invalid_client_secret + ) - // Check for bad auth syntax - if (auth.length != 2) { - let msg = "The decoded header must contain '{client_id}:{client_secret}'"; - Lib.replyWithError(res, "bad_auth_header", 401, authHeader, msg); - return false; - } - } - return true; + let auth = authHeader.replace(/^basic\s*/i, "") + + // Check for empty auth + assert(auth, errors.client_credentials.empty_auth_header, authHeader) + + // Check for invalid base64 + assert( + () => auth = Buffer.from(auth, "base64").toString().split(":"), + errors.client_credentials.bad_auth_header, + authHeader + ) + + // Check for bad auth syntax + assert( + auth.length === 2, + errors.client_credentials.bad_auth_header, + authHeader, + "The decoded header must contain '{client_id}:{client_secret}'" + ) } /** @@ -237,71 +217,64 @@ class TokenHandler extends SMARTHandler { * @param {Object} clientDetailsToken */ finish(clientDetailsToken) { - try { - const req = this.request; - const res = this.response; - - // Request from confidential client - if (!this.validateAuth(clientDetailsToken)) { - return; - } - - const scope = new ScopeSet(decodeURIComponent(clientDetailsToken.scope)); - - if (clientDetailsToken.auth_error == "token_invalid_token") { - return Lib.replyWithError(res, "sim_invalid_token", 401); - } - - // refresh_token - if (scope.has('offline_access') || scope.has('online_access')) { - clientDetailsToken.context.refresh_token = Lib.generateRefreshToken(clientDetailsToken); - } - const expiresIn = clientDetailsToken.accessTokensExpireIn ? - clientDetailsToken.accessTokensExpireIn * 60 : - req.body.grant_type === 'client_credentials' ? - config.backendServiceAccessTokenLifetime * 60 : - config.accessTokenLifetime * 60; - - var token = Object.assign({}, clientDetailsToken.context, { - token_type: "bearer", - scope : clientDetailsToken.scope, - client_id : req.body.client_id, - expires_in: expiresIn - }); - - // sim_error - if (clientDetailsToken.auth_error == "request_invalid_token") { - token.sim_error = "Invalid token"; - } else if (clientDetailsToken.auth_error == "request_expired_token") { - token.sim_error = "Token expired"; - } - - // id_token - if (clientDetailsToken.user && scope.has("openid") && (scope.has("profile") || scope.has("fhirUser"))) { - token.id_token = this.createIdToken(clientDetailsToken); - } + const req = this.request; + const res = this.response; - if (clientDetailsToken.sde) { - token.serviceDiscoveryURL = clientDetailsToken.sde - } + // Request from confidential client + this.validateBasicAuth(clientDetailsToken) + + const scope = new ScopeSet(decodeURIComponent(clientDetailsToken.scope)); + + assert(clientDetailsToken.auth_error != "token_invalid_token", errors.client_credentials.invalid_token); - // access_token - token.access_token = jwt.sign(token, config.jwtSecret, { expiresIn }); - - // The authorization servers response must include the HTTP - // Cache-Control response header field with a value of no-store, - // as well as the Pragma response header field with a value of no-cache. - res.set({ - "Cache-Control": "no-store", - "Pragma": "no-cache" - }); - - res.json(token); - } catch (ex) { - console.error(ex); - throw ex; + // refresh_token + if (scope.has('offline_access') || scope.has('online_access')) { + clientDetailsToken.context.refresh_token = Lib.generateRefreshToken(clientDetailsToken); } + + const expiresIn = clientDetailsToken.accessTokensExpireIn ? + clientDetailsToken.accessTokensExpireIn * 60 : + req.body.grant_type === 'client_credentials' ? + +config.backendServiceAccessTokenLifetime * 60 : + +config.accessTokenLifetime * 60; + + var token = { + ...clientDetailsToken.context, + token_type: "bearer", + scope : clientDetailsToken.scope, + client_id : req.body.client_id, + expires_in: expiresIn + }; + + // sim_error + if (clientDetailsToken.auth_error == "request_invalid_token") { + token.sim_error = "Invalid token"; + } else if (clientDetailsToken.auth_error == "request_expired_token") { + token.sim_error = "Token expired"; + } + + // id_token + if (clientDetailsToken.user && scope.has("openid") && (scope.has("profile") || scope.has("fhirUser"))) { + token.id_token = this.createIdToken(clientDetailsToken); + } + + if (clientDetailsToken.sde) { + token.serviceDiscoveryURL = clientDetailsToken.sde + } + + // access_token + token.access_token = jwt.sign(token, config.jwtSecret, { expiresIn }); + + // The authorization servers response must include the HTTP + // Cache-Control response header field with a value of no-store, + // as well as the Pragma response header field with a value of no-cache. + res.set({ + "Cache-Control": "no-store", + "Pragma": "no-cache" + }); + + res.json(token); } } diff --git a/src/config.js b/src/config.js index 64eab926..b4db067c 100644 --- a/src/config.js +++ b/src/config.js @@ -36,40 +36,5 @@ module.exports = { "kid": "9c37bf73343adb93920a7ae80260b0e57684551e", "use": "sig" }, JWK), - errors: { - "missing_parameter" : "Missing %s parameter", - "invalid_parameter" : "Invalid %s parameter", - "missing_response_type_parameter" : "Missing response_type parameter", - "missing_client_id_parameter" : "Missing client_id parameter", - "missing_scope_parameter" : "Missing scope parameter", - "missing_state_parameter" : "Missing state parameter", - "missing_redirect_uri_parameter" : "Missing redirect_uri parameter", - "bad_redirect_uri" : "Bad redirect_uri: %s", - "bad_audience" : "Bad audience value", - "no_redirect_uri_protocol" : "Invalid redirect_uri parameter '%s' (must be full URL)", - "unauthorized" : "Unauthorized", - "form_content_type_required" : "Invalid request content-type header (must be 'application/x-www-form-urlencoded')", - "sim_invalid_client_id" : "Simulated invalid client_id parameter error", - "sim_invalid_redirect_uri" : "Simulated invalid redirect_uri parameter error", - "sim_invalid_scope" : "Simulated invalid scope error", - "sim_invalid_client_secret" : "Simulated invalid client secret error", - "sim_invalid_token" : "Simulated invalid token error", - "sim_expired_refresh_token" : "Simulated expired refresh token error", - "invalid_token" : "Invalid token: %s", - "empty_auth_header" : "The authorization header '%s' cannot be empty", - "bad_auth_header" : "Bad authorization header '%s': %s", - "missing_client_assertion_type" : "Missing client_assertion_type parameter", - "invalid_client_assertion_type" : "Invalid client_assertion_type parameter. Must be 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'.", - "invalid_jti" : "Invalid 'jti' value", - "invalid_aud" : "Invalid token 'aud' value. Must be '%s'.", - "invalid_token_iss" : "The given service url '%s' does not match the registered '%s'", - "token_expired_registration_token": "Registration token expired", - "invalid_registration_token" : "Invalid registration token: %s", - "invalid_client_details_token" : "Invalid client details token: %s", - "invalid_scope" : 'Invalid scope: "%s"', - "missing_scope" : "Empty scope", - "token_invalid_scope" : "Simulated invalid scope error", - "bad_grant_type" : "Unknown or missing grant_type parameter" - }, includeEncounterContextInStandaloneLaunch: true } diff --git a/src/errors.js b/src/errors.js new file mode 100644 index 00000000..5d39c75f --- /dev/null +++ b/src/errors.js @@ -0,0 +1,199 @@ +module.exports = { + + // Common errors ---------------------------------------------------------- + + // Happens on the token endpoint + bad_grant_type: { + error: "unsupported_grant_type", + msg : 'Invalid or missing grant_type parameter "%s"', + code : 400, + type : "oauth" + }, + + // Happens on POST endpoints (token, register...) + form_content_type_required: { + error: "invalid_request", + msg : "Invalid request content-type header (must be 'application/x-www-form-urlencoded')", + code : 400, + type : "oauth" + }, + + // SMART code flow -------------------------------------------------------- + authorization_code: { + + // On this server the registered client object is a JWT passed as code + invalid_code: { + error: "invalid_client", + msg : "Invalid token (supplied as code parameter in the POST body)", + code : 400, + type : "oauth" + }, + + bad_redirect_uri: { + error: "invalid_request", + msg : "Bad redirect_uri: %s", + code : 400, + type : "oauth" + }, + + sim_invalid_scope: { + error: "invalid_scope", + msg : "Simulated invalid scope error", + code : 302, + type : "oauth" + }, + + sim_invalid_redirect_uri: { + error: "invalid_request", + msg : "Simulated invalid redirect_uri parameter error", + code : 400, + type : "oauth" + }, + + sim_invalid_client_id: { + error: "invalid_client", + msg : "Simulated invalid client_id parameter error", + code : 302, + type : "oauth" + }, + + unauthorized: { + error: "invalid_request", + msg : "Unauthorized", + code : 302, + type : "oauth" + }, + + no_redirect_uri_protocol: { + error: "invalid_request", + msg : "Invalid redirect_uri parameter '%s' (must be full URL)", + code : 400, + type : "oauth" + } + }, + + // SMART refreshToken flow ------------------------------------------------ + refresh_token: { + invalid_refresh_token: { + error: "invalid_grant", + msg : "Invalid refresh token", + code : 401, + type : "oauth" + }, + expired_refresh_token: { + error: "invalid_grant", + msg : "Expired refresh token", + code : 403, + type : "oauth" + } + }, + + // Backend Services + client_credentials: { + missing_client_assertion_type: { + error: "invalid_request", + msg : 'Missing "client_assertion_type" parameter', + code : 400, + type : "oauth" + }, + invalid_client_assertion_type: { + error: "invalid_request", + msg : 'Invalid client_assertion_type parameter. Must be "urn:ietf:params:oauth:client-assertion-type:jwt-bearer".', + code : 400, + type : "oauth" + }, + missing_registration_token: { + error: "invalid_request", + msg : 'Missing "client_assertion" parameter. Must be a JWT.', + code : 400, + type : "oauth" + }, + invalid_registration_token: { + error: "invalid_request", + msg : 'Invalid "client_assertion" parameter. Must be a JWT.', + code : 400, + type : "oauth" + }, + invalid_client_details_token: { + error: "invalid_client", + msg : "Invalid client details token: %s", + code : 401, + type : "oauth" + }, + token_expired_registration_token: { + error: "invalid_client", + msg : "Registration token expired", + code : 401, + type : "oauth" + }, + invalid_aud: { + error: "invalid_client", + msg : "Invalid token 'aud' value. Must be '%s'.", + code : 401, + type : "oauth" + }, + invalid_token_iss: { + error: "invalid_client", + msg : "The given service url '%s' does not match the registered '%s'", + code : 401, + type : "oauth" + }, + invalid_jti: { + error: "invalid_client", + msg : "Invalid 'jti' value", + type : "oauth", + code : 401 + }, + invalid_scope: { + error: "invalid_scope", + msg : 'Invalid scope: "%s"', + type : "oauth", + code : 403 + }, + simulated_invalid_scope: { + error: "invalid_scope", + msg : "Simulated invalid scope error", + type : "oauth", + code : 403 + }, + invalid_token: { + error: "invalid_client", + msg : "Invalid token!", + type : "oauth", + code : 401 + }, + simulated_invalid_client_secret: { + error: "invalid_client", + msg : "Simulated invalid client secret error", + type : "oauth", + code : 401 + }, + empty_auth_header: { + error: "invalid_request", + msg : "The authorization header '%s' cannot be empty", + type : "oauth", + code : 401 + }, + bad_auth_header: { + error: "invalid_request", + msg : "Bad authorization header '%s': %s", + type : "oauth", + code : 401 + } + }, + + registration: { + missing_param: { + error: "invalid_request", + msg : 'Missing parameter "%s"', + code : 400, + type : "oauth" + }, + invalid_param: { + error: "invalid_request", + msg : 'Invalid parameter "%s"', + code : 400, + type : "oauth" + } + } +}; diff --git a/src/fhir-error.js b/src/fhir-error.js deleted file mode 100644 index 487a8d5e..00000000 --- a/src/fhir-error.js +++ /dev/null @@ -1,27 +0,0 @@ -const Lib = require("./lib"); - -module.exports = function(message) { - return { - "resourceType": "OperationOutcome", - "text": { - "status": "generated", - "div": `
-

Operation Outcome

- - - - - - -
ERROR[]
${Lib.htmlEncode(message)}
-
` - }, - "issue": [ - { - "severity": "error", - "code": "processing", - "diagnostics": message - } - ] - }; -}; diff --git a/src/fhir-server.js b/src/fhir-server.js index b71f5014..074f2cdc 100644 --- a/src/fhir-server.js +++ b/src/fhir-server.js @@ -11,10 +11,9 @@ const base64url = require("base64-url") const wellKnownOIDC = require("./wellKnownOIDCConfiguration") const wellKnownSmart = require("./wellKnownSmartConfiguration") const AuthorizeHandler = require("./AuthorizeHandler") -const RegistrationHandler = require("./RegistrationHandler") +const handleRegistration = require("./RegistrationHandler") const TokenHandler = require("./TokenHandler") const { introspectionHandler } = require("./introspect") -const lib = require("./lib") const simpleProxy = require("./simple-proxy") const fhirServer = module.exports = express.Router({ mergeParams: true }) @@ -28,7 +27,7 @@ fhirServer.get("/auth/authorize", AuthorizeHandler.handleRequest) fhirServer.post("/auth/token", urlencoded, TokenHandler.handleRequest) -fhirServer.post("/auth/register", urlencoded, RegistrationHandler.handleRequest) +fhirServer.post("/auth/register", urlencoded, handleRegistration) fhirServer.post("/auth/introspect", urlencoded, introspectionHandler) @@ -70,17 +69,6 @@ fhirServer.post("/fhir/_services/smart/launch", express.json(), (req, res) => { }); // Proxy everything else under `/fhir` to the underlying FHIR server -fhirServer.use("/fhir", text, handleParseError, simpleProxy); +fhirServer.use("/fhir", text, simpleProxy); - -function handleParseError(err, req, res, next) { - if (err instanceof SyntaxError && err.status === 400) { - return lib.operationOutcome( - res, - `Failed to parse JSON content, error was: ${err.message}`, - { httpCode: 400 } - ); - } - next(err, req, res); -} diff --git a/src/index.js b/src/index.js index 10990cff..2792fa43 100644 --- a/src/index.js +++ b/src/index.js @@ -8,7 +8,8 @@ const launcher = require("./launcher") const fhirServer = require("./fhir-server") const { rejectXml, - blackList + blackList, + globalErrorHandler } = require("./middlewares") @@ -62,7 +63,6 @@ app.use("/env.js", (req, res) => { const whitelist = { "NODE_ENV" : String, - "LOG_TIMES" : lib.bool, "DISABLE_BACKEND_SERVICES": lib.bool, "GOOGLE_ANALYTICS_ID" : String, "CDS_SANDBOX_URL" : String, @@ -84,13 +84,16 @@ app.use("/env.js", (req, res) => { } }); - res.type("javascript").send(`var ENV = ${JSON.stringify(out, null, 4)};`); + res.type("application/javascript").send(`var ENV = ${JSON.stringify(out, null, 4)};`); }); // static assets app.use(express.static("static")); +app.use(globalErrorHandler); + // Start the server if ran directly (tests import it and start it manually) +/* istanbul ignore if */ if (require.main?.filename === __filename) { app.listen(config.port, () => { console.log(`SMART launcher listening on port ${config.port}!`) diff --git a/src/launcher.js b/src/launcher.js index fedd11c7..9cf48be0 100644 --- a/src/launcher.js +++ b/src/launcher.js @@ -38,7 +38,7 @@ module.exports = (req, res) => { proto = proto[0]; // fhir_ver - fhir_ver = parseInt(fhir_ver || "0", 10); + fhir_ver = parseInt(fhir_ver + "", 10); if (fhir_ver != 2 && fhir_ver != 3 && fhir_ver != 4) { return res.status(400).send("Invalid or missing fhir_ver parameter. It can only be '2', '3' or '4'."); } diff --git a/src/lib.js b/src/lib.js index 049f1d14..00921425 100644 --- a/src/lib.js +++ b/src/lib.js @@ -1,27 +1,10 @@ const jwt = require("jsonwebtoken"); -const Url = require("url"); -const replaceAll = require("replaceall"); const config = require("./config"); +const util = require("util") +const { STATUS_CODES } = require("http"); +const OperationOutcome = require("./OperationOutcome"); -const RE_GT = />/g; -const RE_LT = / out ? out[key] : undefined, obj) } -/** - * Simplified version of printf. Just replaces all the occurrences of "%s" with - * whatever is supplied in the rest of the arguments. If no argument is supplied - * the "%s" token is left as is. - * @param {String} s The string to format - * @param {*[]} ... The rest of the arguments are used for the replacements - * @return {String} - */ -function printf(s) { - var args = arguments, l = args.length, i = 0; - return String(s || "").replace(/(%s)/g, a => ++i > l ? "" : args[i]); -} - -function die(error="Unknown error") { - console.log("\n"); // in case we have something written to stdout directly - console.error(error); - process.exit(1); -} - function generateRefreshToken(code) { let token = {}; ["context", "client_id", "scope", "user", "iat"/*, "exp"*/, "auth_error"].forEach(key => { @@ -63,30 +27,10 @@ function generateRefreshToken(code) { } }); return jwt.sign(token, config.jwtSecret, { - expiresIn: config.refreshTokenLifeTime * 60 + expiresIn: +config.refreshTokenLifeTime * 60 }); } -function redirectWithError(req, res, name, ...rest) { - let redirectURL = Url.parse(req.query.redirect_uri, true); - redirectURL.query.error = name; - redirectURL.query.error_description = getErrorText(name, ...rest); - if (req.query.state) { - redirectURL.query.state = req.query.state; - } - return res.redirect(Url.format(redirectURL)); -} - -function replyWithError(res, name, code = 500, ...params) { - res.status(code) - res.set('Content-Type', 'text/plain') - return res.send(getErrorText(name, ...params)); -} - -function getErrorText(name, ...rest) { - return printf(config.errors[name], ...rest); -} - function getFirstMissingProperty(object, properties) { return (Array.isArray(properties) ? properties : [properties]).find( param => !object[param] @@ -97,68 +41,13 @@ function bool(x) { return !RE_FALSE.test(String(x).trim()); } -function parseToken(token) { - if (typeof token != "string") { - throw new Error("The token must be a string"); - } - - token = token.split("."); - - if (token.length != 3) { - throw new Error("Invalid token structure"); - } - - return JSON.parse(new Buffer(token[1], "base64").toString("utf8")); -} - -// require a valid auth token if there is an auth token -function checkAuth(req, res, next) { - if (req.headers.authorization) { - try { - token = jwt.verify( - req.headers.authorization.split(" ")[1], - config.jwtSecret - ); - } catch (e) { - return res.status(401).send( - `${e.name || "Error"}: ${e.message || "Invalid token"}` - ); - } - if (token.sim_error) { - return res.status(401).send(token.sim_error); - } - } - next(); -} - function operationOutcome(res, message, { httpCode = 500, issueCode = "processing", // http://hl7.org/fhir/valueset-issue-type.html severity = "error" // fatal | error | warning | information } = {}){ - return res.status(httpCode).json({ - "resourceType": "OperationOutcome", - "text": { - "status": "generated", - "div": `
-

Operation Outcome

- - - - - - -
ERROR[]
${htmlEncode(message)}
-
` - }, - "issue": [ - { - "severity" : severity, - "code" : issueCode, - "diagnostics": message - } - ] - }); + const oo = new OperationOutcome(message, issueCode, severity) + return res.status(httpCode).json(oo.toJSON()); } /** @@ -184,103 +73,13 @@ function normalizeUrl(url) { return buildUrlPath(url).toLowerCase(); } -/** - * Given a conformance statement (as JSON string), replaces the auth URIs with - * new ones that point to our proxy server. Also add the rest.security.service - * field. - * @param {String} bodyText A conformance statement as JSON string - * @param {String} baseUrl The baseUrl of our server - * @returns {Object|null} Returns the modified JSON object or null in case of error - */ -function augmentConformance(bodyText, baseUrl) { - let json; - try { - json = JSON.parse(bodyText); - if (!json.rest[0].security) { - json.rest[0].security = {}; - } - } catch (e) { - console.error(e); - return null; - } - - json.rest[0].security.extension = [{ - "url": "http://fhir-registry.smarthealthit.org/StructureDefinition/oauth-uris", - "extension": [ - { - "url": "authorize", - "valueUri": buildUrlPath(baseUrl, "/auth/authorize") - }, - { - "url": "token", - "valueUri": buildUrlPath(baseUrl, "/auth/token") - }, - { - "url": "introspect", - "valueUri": buildUrlPath(baseUrl, "/auth/introspect") - } - ] - }]; - - json.rest[0].security.service = [ - { - "coding": [ - { - "system": "http://hl7.org/fhir/restful-security-service", - "code": "SMART-on-FHIR", - "display": "SMART-on-FHIR" - } - ], - "text": "OAuth2 using SMART-on-FHIR profile (see http://docs.smarthealthit.org)" - } - ] - - return json; -} - -function unBundleResource(bundle) { - try { - return JSON.parse(bundle).entry[0].resource; - } catch (e) { - return null; - } -} - -function adjustResponseUrls(bodyText, fhirUrl, requestUrl, fhirBaseUrl, requestBaseUrl) { - bodyText = replaceAll(fhirUrl, requestUrl, bodyText); - bodyText = replaceAll(fhirUrl.replace(",", "%2C"), requestUrl, bodyText); // allow non-encoded commas - bodyText = replaceAll("/fhir", requestBaseUrl, bodyText); - return bodyText; -} - -function adjustUrl(url) { - return url.replace(RE_RESOURCE_SLASH_ID, ( - resourceAndId, - resource, - slashAndId, - id, - suffix, - slashAndQuery, - query - ) => resource + "?" + (query ? query + "&" : "") + "_id=" + id); -} - -function adjustRequestBody(json) { - (json.entry || [{resource: json}]).forEach( entry => { - if (entry.request) { - entry.request.url = adjustUrl(entry.request.url); - } - }); - return json; -} - /** * Checks if the currentValue is within the white-listed values. If so, returns * it. Otherwise returns the defaultValue if one is set, or throws an exception * if no defaultValue is provided. * @param {any[]} allowedValues * @param {*} currentValue - * @param {*} defaultValue + * @param {*} [defaultValue] */ function whitelist(allowedValues, currentValue, defaultValue) { if (allowedValues.indexOf(currentValue) == -1) { @@ -296,27 +95,156 @@ function whitelist(allowedValues, currentValue, defaultValue) { return currentValue } +/** + * @param {*} condition + * @param {string|Error|{error:string, msg:string, code:number, type:string}} message + * @param {any[]} rest + * @returns {asserts condition} + */ +function assert(condition, message, ...rest) { + if (typeof condition == "function") { + try { + condition() + } catch (ex) { + assert.fail(message, ...rest, ex.message) + } + } + else if (!(condition)) { + assert.fail(message, ...rest) + } +} + +/** + * @param {*} condition + * @param {number} code + * @param {string} [message] + * @param {any[]} rest + */ +assert.http = function(condition, code, message="", ...rest) { + if (typeof condition == "function") { + try { + condition() + } catch (ex) { + throw new HTTPError(code, util.format(message, ...rest, ex.message)) + } + } + else if (!(condition)) { + throw new HTTPError(code, util.format(message, ...rest)) + } +} + +/** + * @param {string|Error|{error:string, msg:string, code:number, type?:string}} error + * @param {any[]} rest + * @throws {HTTPError} + */ +assert.fail = function(error, ...rest) { + if (error && typeof error === "object") { + if (error instanceof Error) { + throw error + } + + const { msg, code, type } = error + + if (type === "oauth") { + throw new OAuthError(code, util.format(msg, ...rest), error.error) + } + + if (type === "OperationOutcome") { + throw new OperationOutcomeError(code, util.format(msg, ...rest)) + } + + throw new HTTPError(code, util.format(msg, ...rest)) + } + + throw new HTTPError(500, util.format(error, ...rest)) +} + +/////////////////////////////////////////////////////////////////////////////// +class HTTPError extends Error +{ + /** + * @type {number} + */ + status; + + /** + * @param {number} status + * @param {string} [message] + */ + constructor(status, message = "") { + super(message || STATUS_CODES[status] || "Unknown error") + this.status = status + } + + render(req, res) { + res.status(this.status).type("text").end(this.message) + } +} +class OAuthError extends HTTPError +{ + /** + * @type {string} + */ + errorId; + + /** + * @param {number} status + * @param {string} message + * @param {string} errorId + */ + constructor(status, message, errorId) { + super(status, message) + this.errorId = errorId + } + + /** + * + * @param {{msg:string,code:number,error:string,[key:string]:any}} config + */ + static from({ msg, code, error }, ...args) { + return new OAuthError(code, util.format(msg, ...args), error) + } + + render(req, res) { + if ([301, 302, 303, 307, 308].indexOf(this.status) > -1) { + let redirectURL = new URL(req.query.redirect_uri); + redirectURL.searchParams.set("error", this.errorId); + redirectURL.searchParams.set("error_description", this.message); + if (req.query.state) { + redirectURL.searchParams.set("state", req.query.state); + } + return res.redirect(this.status, redirectURL.href); + } + + return res.status(this.status).json({ + error: this.errorId, + error_description: this.message + }); + } +} + +class OperationOutcomeError extends HTTPError +{ + render(req, res) { + operationOutcome(res, this.message, { httpCode: this.status }) + } +} + + module.exports = { getPath, generateRefreshToken, - printf, - redirectWithError, - replyWithError, - getErrorText, getFirstMissingProperty, - htmlEncode, bool, - parseToken, - checkAuth, operationOutcome, buildUrlPath, - augmentConformance, normalizeUrl, - unBundleResource, - adjustResponseUrls, - adjustUrl, - adjustRequestBody, getRequestBaseURL, whitelist, - die + assert, + + HTTPError, + OAuthError, + OperationOutcomeError }; diff --git a/src/middlewares.js b/src/middlewares.js index 9b706511..52032dbf 100644 --- a/src/middlewares.js +++ b/src/middlewares.js @@ -1,15 +1,16 @@ -const { operationOutcome } = require("./lib"); +const { HTTPError, operationOutcome } = require("./lib"); -function rejectXml(err, req, res, next) { +function rejectXml(req, res, next) { if ( - (req.headers.accept && req.headers.accept.indexOf("xml") != -1) || + // !req.accepts("json") || + // (req.headers.accept && req.headers.accept.indexOf("xml") != -1) || (req.headers['content-type'] && req.headers['content-type'].indexOf("xml") != -1) || /_format=.*xml/i.test(req.url) ) { return operationOutcome(res, "XML format is not supported", { httpCode: 400 }); } - next(err, req, res) + next() } // const handleParseError = function(err, req, res, next) { @@ -65,7 +66,25 @@ function blackList(ipList) { } } +/** + * Global error 500 handler + * @param {Error} error + * @param {import('express').Request} req + * @param {import('express').Response} res + * @param {import('express').NextFunction} next + */ +function globalErrorHandler(error, req, res, next) +{ + if (error instanceof HTTPError) { + return error.render(req, res) + } + + console.error(error); + res.status(500).end('Internal Server Error'); +} + module.exports = { rejectXml, - blackList + blackList, + globalErrorHandler } \ No newline at end of file diff --git a/src/patient-compartment.js b/src/patient-compartment.js deleted file mode 100644 index 988396cf..00000000 --- a/src/patient-compartment.js +++ /dev/null @@ -1,195 +0,0 @@ -module.exports = { - "r2": { - Account: "subject", - AllergyIntolerance: "patient", - Appointment: "actor", - AppointmentResponse: "actor", - AuditEvent: "patient", - Basic: "patient", - BodySite: "patient", - CarePlan: "patient", - Claim: "patient", - ClinicalImpression: "patient", - Communication: "subject", - CommunicationRequest: "subject", - Composition: "subject", - Condition: "patient", - DetectedIssue: "patient", - DeviceUseRequest: "subject", - DeviceUseStatement: "subject", - DiagnosticOrder: "subject", - DiagnosticReport: "subject", - DocumentManifest: "subject", - DocumentReference: "subject", - Encounter: "patient", - EnrollmentRequest: "subject", - EpisodeOfCare: "patient", - FamilyMemberHistory: "patient", - Flag: "patient", - Goal: "patient", - Group: "member", - ImagingObjectSelection: "patient", - ImagingStudy: "patient", - Immunization: "patient", - ImmunizationRecommendation: "patient", - List: "subject", - Media: "subject", - MedicationAdministration: "patient", - MedicationDispense: "patient", - MedicationOrder: "patient", - MedicationStatement: "patient", - NutritionOrder: "patient", - Observation: "subject", - Order: "subject", - Patient: "_id", - Person: "patient", - Procedure: "patient", - ProcedureRequest: "subject", - Provenance: "patient", - QuestionnaireResponse: "subject", - ReferralRequest: "patient", - RelatedPerson: "patient", - RiskAssessment: "subject", - Schedule: "actor", - Specimen: "subject", - SupplyDelivery: "patient", - SupplyRequest: "patient", - VisionPrescription: "patient" - }, - "r3": { - Account: "subject", - AdverseEvent: "subject", - AllergyIntolerance: "patient", - Appointment: "actor", - AppointmentResponse: "actor", - AuditEvent: "patient", - Basic: "patient", - BodySite: "patient", - CarePlan: "patient", - CareTeam: "patient", - ChargeItem: "subject", - Claim: "patient", - ClaimResponse: "patient", - ClinicalImpression: "subject", - Communication: "subject", - CommunicationRequest: "subject", - Composition: "subject", - Condition: "patient", - Consent: "patient", - Coverage: "policy-holder", - DetectedIssue: "patient", - DeviceRequest: "subject", - DeviceUseStatement: "subject", - DiagnosticReport: "subject", - DocumentManifest: "subject", - DocumentReference: "subject", - EligibilityRequest: "patient", - Encounter: "patient", - EnrollmentRequest: "subject", - EpisodeOfCare: "patient", - ExplanationOfBenefit: "patient", - FamilyMemberHistory: "patient", - Flag: "patient", - Goal: "patient", - Group: "member", - ImagingManifest: "patient", - ImagingStudy: "patient", - Immunization: "patient", - ImmunizationRecommendation: "patient", - List: "subject", - MeasureReport: "patient", - Media: "subject", - MedicationAdministration: "patient", - MedicationDispense: "subject", - MedicationRequest: "subject", - MedicationStatement: "subject", - NutritionOrder: "patient", - Observation: "subject", - Patient: "_id", - Person: "patient", - Procedure: "patient", - ProcedureRequest: "subject", - Provenance: "patient", - QuestionnaireResponse: "subject", - ReferralRequest: "patient", - RelatedPerson: "patient", - RequestGroup: "subject", - ResearchSubject: "individual", - RiskAssessment: "subject", - Schedule: "actor", - Specimen: "subject", - SupplyDelivery: "patient", - SupplyRequest: "requester", - VisionPrescription: "patient" - }, - "r4": { - - // Resource Inclusion Criteria - "Account" : "subject", - "AdverseEvent" : "subject", - "AllergyIntolerance" : "patient", // or recorder or asserter - "Appointment" : "actor", - "AppointmentResponse" : "actor", - "AuditEvent" : "patient", - "Basic" : "patient", // or author - "BodyStructure" : "patient", - "CarePlan" : "patient", // or performer - "CareTeam" : "patient", // or participant - "ChargeItem" : "subject", - "Claim" : "patient", // or payee - "ClaimResponse" : "patient", - "ClinicalImpression" : "subject", - "Communication" : "subject", // or sender or recipient - "CommunicationRequest" : "subject", // or sender or recipient or requester - "Composition" : "subject", // or author or attester - "Condition" : "patient", // or asserter - "Consent" : "patient", - "Coverage" : "policy-holder", // or subscriber or beneficiary or payor - "CoverageEligibilityRequest" : "patient", - "CoverageEligibilityResponse": "patient", - "DetectedIssue" : "patient", - "DeviceRequest" : "subject", // or performer - "DeviceUseStatement" : "subject", - "DiagnosticReport" : "subject", - "DocumentManifest" : "subject", // or author or recipient - "DocumentReference" : "subject", // or author - "Encounter" : "patient", - "EnrollmentRequest" : "subject", - "EpisodeOfCare" : "patient", - "ExplanationOfBenefit" : "patient", // or payee - "FamilyMemberHistory" : "patient", - "Flag" : "patient", - "Goal" : "patient", - "Group" : "member", - "ImagingStudy" : "patient", - "Immunization" : "patient", - "ImmunizationEvaluation" : "patient", - "ImmunizationRecommendation" : "patient", - "Invoice" : "subject", // or patient or recipient - "List" : "subject", // or source - "MeasureReport" : "patient", - "Media" : "subject", - "MedicationAdministration" : "patient", // or performer or subject - "MedicationDispense" : "subject", // or patient or receiver - "MedicationRequest" : "subject", - "MedicationStatement" : "subject", - "MolecularSequence" : "patient", - "NutritionOrder" : "patient", - "Observation" : "subject", // or performer - "Patient" : "link", - "Person" : "patient", - "Procedure" : "patient", // or performer - "Provenance" : "patient", - "QuestionnaireResponse" : "subject", // or author - "RelatedPerson" : "patient", - "RequestGroup" : "subject", // or participant - "ResearchSubject" : "individual", - "RiskAssessment" : "subject", - "Schedule" : "actor", - "ServiceRequest" : "subject", // or performer - "Specimen" : "subject", - "SupplyDelivery" : "patient", - "SupplyRequest" : "subject", - "VisionPrescription" : "patient" - } -}; diff --git a/src/reverse-proxy.js b/src/reverse-proxy.js deleted file mode 100644 index 3b83cb33..00000000 --- a/src/reverse-proxy.js +++ /dev/null @@ -1,159 +0,0 @@ -const request = require("request"); -const jwt = require("jsonwebtoken"); -const config = require("./config"); -const fhirError = require("./fhir-error"); -const patientMap = require("./patient-compartment"); -const Lib = require("./lib"); -require("colors"); - -// Pre-define any RegExp as global to improve performance -const RE_RESOURCE_SLASH_ID = new RegExp( - "([A-Z]\\w+)" + // Resource type - "(\\/([^\\/?]+))" + // Resource ID - "\\/?(\\?|$)" // Anything after (like a query string) -); - -module.exports = function (req, res) { - - let logTime = Lib.bool(process.env.LOG_TIMES) ? Date.now() : null; - - let token = null; - let sandboxes = req.params.sandbox && req.params.sandbox.split(","); - let isSearchPost = req.method == "POST" && req.url.endsWith("/_search"); - let fhirRelease = (req.params.fhir_release || "").toUpperCase(); - let fhirServer = config["fhirServer" + fhirRelease]; - - // FHIR_SERVER_R2_INTERNAL and FHIR_SERVER_R2_INTERNAL env variables can be - // set to point the request to different location. This is useful when - // running as a Docker service and the fhir servers are in another service - // container - if (process.env["FHIR_SERVER_" + fhirRelease + "_INTERNAL"]) { - fhirServer = process.env["FHIR_SERVER_" + fhirRelease + "_INTERNAL"]; - } - - if (!fhirServer) { - return res.status(400).send({ - error: `FHIR server ${req.params.fhir_release} not found` - }); - } - - // require a valid auth token if there is an auth token - if (req.headers.authorization) { - try { - token = jwt.verify(req.headers.authorization.split(" ")[1], config.jwtSecret); - } catch (e) { - return res.status(401).send(`${e.name || "Error"}: ${e.message || "Invalid token"}`); - } - if (token.sim_error) { - return res.status(401).send(token.sim_error); - } - } - - // set everything to JSON since we don't currently support XML and block XML - // requests at a middleware layer - let fhirRequest = { - headers: { - "content-type": "application/json", - "accept" : req.params.fhir_release.toUpperCase() == "R2" ? "application/json+fhir" : "application/fhir+json" - }, - method: req.method - } - - // inject sandbox tag into POST and PUT requests and make urls conditional - // ------------------------------------------------------------------------- - if (isSearchPost) { - fhirRequest.body = req.body; - fhirRequest.headers["content-type"] = req.headers["content-type"]; - // fhirRequest.body = String(fhirRequest.body) + "&_tag=" + sandboxes.join("&"); - } - else if (Object.keys(req.body).length) { - fhirRequest.body = Lib.adjustRequestBody(req.body); - fhirRequest.body = Buffer.from(JSON.stringify(fhirRequest.body), 'utf8'); - fhirRequest.headers['content-length'] = Buffer.byteLength(fhirRequest.body) - } - - // make urls conditional and if exists, change /id to ?_id= - if (isSearchPost) { - fhirRequest.url = Lib.buildUrlPath(fhirServer, req.url); - } - else { - fhirRequest.url = Lib.buildUrlPath(fhirServer, Lib.adjustUrl(req.url)); - } - - // if applicable, apply patient scope to GET requests, largely for - // performance reasons. Full scope support can't be implemented in a proxy - // because it would require "or" conditions in FHIR API calls (ie ), - // but should do better than this! - let scope = (token && token.scope) || req.headers["x-scope"]; - let patient = (token && token.patient) || req.headers["x-patient"]; - if (req.method == "GET" && scope && patient && scope.indexOf("user/") == -1) { - let resourceType = req.url.slice(1); - let map = patientMap[req.params.fhir_release] && patientMap[req.params.fhir_release][resourceType]; - if (map) fhirRequest.url += "&" + map + "=" + patient; - } - - //proxy the request to the real FHIR server - if (!logTime && process.env.NODE_ENV == "development") { - console.log("PROXY: " + fhirRequest.url, fhirRequest); - } - - request(fhirRequest, function(error, response, body) { - if (error) { - // res.status(500) - // res.type("application/json") - return res.send(JSON.stringify(error, null, 4)); - } - res.status(response.statusCode); - response.headers['content-type'] && res.type(response.headers['content-type']); - - // adjust urls in the fhir response so future requests will hit the proxy - if (body) { - let requestUrl = Lib.buildUrlPath(config.baseUrl, req.originalUrl); - let requestBaseUrl = Lib.buildUrlPath(config.baseUrl, req.baseUrl) - body = Lib.adjustResponseUrls(body, fhirRequest.url, requestUrl, fhirServer, requestBaseUrl); - } - - // special handler for metadata requests - inject the SMART information - if (req.url.match(/^\/metadata/) && response.statusCode == 200 && body.indexOf("fhirVersion") != -1) { - let baseUrl = Lib.buildUrlPath(config.baseUrl, req.baseUrl.replace("/fhir", "")); - let secure = req.secure || req.headers["x-forwarded-proto"] == "https"; - baseUrl = baseUrl.replace(/^https?/, secure ? "https" : "http"); - body = Lib.augmentConformance(body, baseUrl); - if (!body) { - res.status(404); - body = fhirError(`Error reading server metadata`); - } - } - - // pull the resource out of the bundle if we converted a /id url into a ?_id= query - if (req.method =="GET" && RE_RESOURCE_SLASH_ID.test(req.url) && typeof body == "string" && body.indexOf("Bundle") != -1) { - body = Lib.unBundleResource(body); - if (!body) { - res.status(404); - body = fhirError(`Resource ${req.url.slice(1)} is not known`); - } - body = JSON.stringify(body, null, 4); - } - - // pretty print if called from a browser - // TODO: use a template and syntax highlight json response - if (req.headers.accept && - req.headers.accept.toLowerCase().indexOf("html") > -1 && - req.originalUrl.toLowerCase().indexOf("_pretty=false") == -1 - ) { - body = (typeof body == "string" ? body : JSON.stringify(body, null, 4)); - body = `
${Lib.htmlEncode(body)}
`; - res.type("html"); - } - - if (logTime) { - console.log( - ("Reverse Proxy: ".bold + fhirRequest.url + " -> ").cyan + - String((Date.now() - logTime) + "ms").yellow.bold - ); - } - - res.send(body); - }); - -}; diff --git a/src/simple-proxy.js b/src/simple-proxy.js index a30eb3a4..6e506ab3 100644 --- a/src/simple-proxy.js +++ b/src/simple-proxy.js @@ -1,27 +1,144 @@ -// @ts-check const Url = require("url"); const request = require("request"); const jwt = require("jsonwebtoken"); const replStream = require("replacestream"); const config = require("./config"); -const patientMap = require("./patient-compartment"); +const debug = require('util').debuglog("proxy"); const Lib = require("./lib"); +const OperationOutcome = require("./OperationOutcome"); +const replaceAll = require("replaceall"); + +////** @type {any} */ +const assert = Lib.assert; + +const RE_RESOURCE_SLASH_ID = new RegExp( + "([A-Z][A-Za-z]+)" + // resource type + "(\\/([^_][^\\/?]+))" + // resource id + "(\\/?(\\?(.*))?)?" // suffix (query) +); + + +/** + * Given a conformance statement (as JSON string), replaces the auth URIs with + * new ones that point to our proxy server. Also add the rest.security.service + * field. + * @param {String} bodyText A conformance statement as JSON string + * @param {String} baseUrl The baseUrl of our server + * @returns {Object|null} Returns the modified JSON object or null in case of error + */ +function augmentConformance(bodyText, baseUrl) { + let json = JSON.parse(bodyText); + + json.rest[0].security.extension = [{ + "url": "http://fhir-registry.smarthealthit.org/StructureDefinition/oauth-uris", + "extension": [ + { + "url": "authorize", + "valueUri": Lib.buildUrlPath(baseUrl, "/auth/authorize") + }, + { + "url": "token", + "valueUri": Lib.buildUrlPath(baseUrl, "/auth/token") + }, + { + "url": "introspect", + "valueUri": Lib.buildUrlPath(baseUrl, "/auth/introspect") + } + ] + }]; + + json.rest[0].security.service = [ + { + "coding": [ + { + "system": "http://hl7.org/fhir/restful-security-service", + "code": "SMART-on-FHIR", + "display": "SMART-on-FHIR" + } + ], + "text": "OAuth2 using SMART-on-FHIR profile (see http://docs.smarthealthit.org)" + } + ] + + return json; +} + +function adjustResponseUrls(bodyText, fhirUrl, requestUrl, fhirBaseUrl, requestBaseUrl) { + bodyText = replaceAll(fhirUrl, requestUrl, bodyText); + bodyText = replaceAll(fhirUrl.replace(",", "%2C"), requestUrl, bodyText); // allow non-encoded commas + bodyText = replaceAll("/fhir", requestBaseUrl, bodyText); + return bodyText; +} + +function adjustUrl(url) { + return url.replace(RE_RESOURCE_SLASH_ID, ( + resourceAndId, + resource, + slashAndId, + id, + suffix, + slashAndQuery, + query + ) => resource + "?" + (query ? query + "&" : "") + "_id=" + id); +} + +function handleMetadataRequest(req, res, fhirServer) +{ + // set everything to JSON since we don't currently support XML and block XML + // requests at a middleware layer + let fhirRequest = { + headers: { + "content-type": "application/json", + "accept" : req.params.fhir_release.toUpperCase() == "R2" ? "application/json+fhir" : "application/fhir+json" + }, + method: req.method, + url: Lib.buildUrlPath(fhirServer, adjustUrl(req.url)) + } -require("colors"); + if (process.env.NODE_ENV != "test") { + debug(fhirRequest.url, fhirRequest); + } + request(fhirRequest, function(error, response, body) { + if (error) { + return res.send(JSON.stringify(error, null, 4)); + } + res.status(response.statusCode); + response.headers['content-type'] && res.type(response.headers['content-type']); + + // adjust urls in the fhir response so future requests will hit the proxy + if (body) { + let requestUrl = Lib.buildUrlPath(config.baseUrl, req.originalUrl); + let requestBaseUrl = Lib.buildUrlPath(config.baseUrl, req.baseUrl) + body = adjustResponseUrls(body, fhirRequest.url, requestUrl, fhirServer, requestBaseUrl); + } -module.exports = (req, res) => { + // special handler for metadata requests - inject the SMART information + if (response.statusCode == 200 && body.indexOf("fhirVersion") != -1) { + let baseUrl = Lib.buildUrlPath(config.baseUrl, req.baseUrl.replace("/fhir", "")); + let secure = req.secure || req.headers["x-forwarded-proto"] == "https"; + baseUrl = baseUrl.replace(/^https?/, secure ? "https" : "http"); + body = augmentConformance(body, baseUrl); + if (!body) { + res.status(404); + body = new OperationOutcome("Error reading server metadata") + } + } - // We cannot handle the conformance here! - if (req.url.match(/^\/metadata/)) { - return require("./reverse-proxy")(req, res); - } + body = (typeof body == "string" ? body : JSON.stringify(body, null, 4)); - let logTime = Lib.bool(process.env.LOG_TIMES) ? Date.now() : null; + res.send(body); + }); + +} +/** + * @param {import("express").Request} req + */ +function validateToken(req) { let token = null; - // Validate token ---------------------------------------------------------- + // Validate token --------------------------------------------------------- if (req.headers.authorization) { // require a valid auth token if there is an auth token @@ -31,16 +148,22 @@ module.exports = (req, res) => { config.jwtSecret ); } catch (e) { - return res.status(401).send( - `${e.name || "Error"}: ${e.message || "Invalid token"}` - ); + throw new Lib.HTTPError(401, "Invalid token: " + e.message) } - // Simulated errors - if (token.sim_error) { - return res.status(401).send(token.sim_error); - } + assert.http(token, 400, "Invalid token") + assert.http(typeof token == "object", 400, "Invalid token") + + // @ts-ignore + assert.http(!token.sim_error, 401, token.sim_error); } +} + +/** + * @param {import("express").Request} req + * @param {import("express").Response} res + */ +module.exports = (req, res) => { // Validate FHIR Version --------------------------------------------------- let fhirVersion = req.params.fhir_release.toUpperCase(); @@ -61,6 +184,14 @@ module.exports = (req, res) => { }); } + // We cannot handle the conformance here! + if (req.url.match(/^\/metadata/)) { + return handleMetadataRequest(req, res, fhirServer); + } + + validateToken(req); + + // Build the FHIR request options ------------------------------------------ let fhirRequestOptions = { method: req.method, @@ -70,24 +201,8 @@ module.exports = (req, res) => { const isBinary = fhirRequestOptions.url.pathname.indexOf("/Binary/") === 0; - // if applicable, apply patient scope to GET requests, largely for - // performance reasons. Full scope support can't be implemented in a proxy - // because it would require "or" conditions in FHIR API calls (ie ), - // but should do better than this! - if (req.method == "GET") { - let scope = (token && token.scope ) || req.headers["x-scope"]; - let patient = (token && token.patient) || req.headers["x-patient"]; - if (scope && patient && scope.indexOf("user/") == -1) { - let resourceType = req.url.slice(1); - let prop = patientMap[fhirVersionLower] && patientMap[fhirVersionLower][resourceType]; - if (prop) { - fhirRequestOptions.url.query[prop] = patient; - } - } - } - // Add the body in case of POST or PUT ------------------------------------- - if (req.method === "POST" || req.method === "PUT") { + if (req.method === "POST" || req.method === "PUT" || req.method === "PATCH") { fhirRequestOptions.body = req.body; } @@ -103,13 +218,8 @@ module.exports = (req, res) => { "application/fhir+json"; } - if (fhirRequestOptions.headers.hasOwnProperty("host")) { - delete fhirRequestOptions.headers.host; - } - - if (fhirRequestOptions.headers.hasOwnProperty("authorization")) { - delete fhirRequestOptions.headers.authorization; - } + delete fhirRequestOptions.headers.host; + delete fhirRequestOptions.headers.authorization; // remove custom headers for (let name in fhirRequestOptions.headers) { diff --git a/test/api.js b/test/api.js index 34922ca1..6ec1e54c 100644 --- a/test/api.js +++ b/test/api.js @@ -205,7 +205,7 @@ before(async () => { after(closeServer); -describe("Global Routes", () => { +describe("Global stuff", () => { it('index responds with html', () => { return request(app) @@ -225,6 +225,62 @@ describe("Global Routes", () => { expect(res.body.keys.length).to.be.greaterThan(0) }); }); + + it('/public_key hosts the public key as pem', () => { + return request(app) + .get('/public_key') + .expect('Content-Type', /text/) + .expect(200) + .expect(/-----BEGIN PUBLIC KEY-----/) + }); + + it ("/env.js", () => { + return request(app) + .get('/env.js') + .expect('Content-Type', /javascript/) + .expect(200) + .expect(/var ENV = \{/) + }) + + it("rejects xml", async () => { + await request(app) + .get("/") + .set("content-type", "application/xml") + .expect(400, /XML format is not supported/) + }) + + describe("/launcher", () => { + it("requires launch_uri", async () => { + await request(app) + .get('/launcher') + .expect('Content-Type', /text/) + .expect(400, "launch_uri is required") + }) + + it("requires absolute launch_uri", async () => { + await request(app) + .get('/launcher') + .query({ launch_uri: "./test" }) + .expect('Content-Type', /text/) + .expect(400, "Invalid launch_uri parameter") + }) + + it("validates the fhir_ver param", async () => { + await request(app) + .get('/launcher') + .query({ launch_uri: "http://test.dev", fhir_ver: 33 }) + .expect('Content-Type', /text/) + .expect(400, "Invalid or missing fhir_ver parameter. It can only be '2', '3' or '4'.") + }) + + it("works", async () => { + await request(app) + .get('/launcher') + .query({ launch_uri: "http://test.dev", fhir_ver: 3 }) + .expect("location", /^http\:\/\/test\.dev\?iss=.+/) + .expect(302) + }) + }) describe('RSA Generator', () => { @@ -275,6 +331,7 @@ describe("Global Routes", () => { }) }); }); + }); for(const FHIR_VERSION in TESTED_FHIR_SERVERS) { @@ -282,6 +339,15 @@ for(const FHIR_VERSION in TESTED_FHIR_SERVERS) { const SMART = getSmartApi(FHIR_VERSION); describe(`FHIR server ${FHIR_VERSION}`, () => { + + it("Catches body parse errors", async () => { + await request(app) + .post(buildUrl({ fhir: FHIR_VERSION, path: "fhir" }).pathname) + .type("json") + .send('{"a":1,"b":}') + .expect(400) + .expect(/OperationOutcome/) + }) it('can render the patient picker', () => { return request(app) @@ -304,17 +370,37 @@ for(const FHIR_VERSION in TESTED_FHIR_SERVERS) { .expect(200) }); + it ("renders .well-known/smart-configuration", async () => { + return request(app) + .get(buildUrl({ fhir: FHIR_VERSION, path: ".well-known/smart-configuration" }).pathname) + .expect('Content-Type', /json/) + .expect(200) + }) + describe('Proxy', function() { this.timeout(10000); + + it("rejects unknown fhir versions", async () => { + await request(app) + .get(buildUrl({ fhir: FHIR_VERSION + 2, path: "/fhir/Patient"}).pathname) + .expect(400, /FHIR server r\d2 not found/) + }) it('fhir/metadata responds with html in browsers', () => { return request(app) .get(buildUrl({ fhir: FHIR_VERSION, path: "fhir/metadata" }).pathname) .set('Accept', 'text/html') - .expect('Content-Type', /^text\/html/) + .expect('content-type', /application\/(fhir\+json|json\+fhir|json)/) .expect(200) }); + it('removes custom headers', () => { + return request(app) + .get(buildUrl({ fhir: FHIR_VERSION, path: "fhir/Patient" }).pathname) + .set('x-custom', 'whatever') + .expect(res => expect(res.header['x-custom']).to.equal(undefined)) + }); + it ("Validates the FHIR version", () => { return request(app) .get(buildUrl({ fhir: "r300", path: "fhir/metadata" }).pathname) @@ -325,10 +411,10 @@ for(const FHIR_VERSION in TESTED_FHIR_SERVERS) { it ("If auth token is sent - validates it", () => { return request(app) - .get(buildUrl({ fhir: FHIR_VERSION, path: "fhir/metadata" }).pathname) + .get(buildUrl({ fhir: FHIR_VERSION, path: "fhir/Patient" }).pathname) .set("authorization", "Bearer whatever") .expect('Content-Type', /text/) - .expect("JsonWebTokenError: jwt malformed") + .expect(/Invalid token\: /) .expect(401) }); @@ -467,13 +553,6 @@ for(const FHIR_VERSION in TESTED_FHIR_SERVERS) { }); }); - it("rejects invalid authorization tokens", async () => { - await request(app) - .get(buildUrl({ fhir: FHIR_VERSION, path: "/fhir/Patient" }).pathname) - .set("authorization", "bearer invalid-token") - .expect(401, /JsonWebTokenError/); - }) - it ("Can simulate custom token errors", async () => { const token = jwt.sign({ sim_error: "test error" }, config.jwtSecret) await request(app) @@ -506,49 +585,30 @@ for(const FHIR_VERSION in TESTED_FHIR_SERVERS) { }; const authErrors = { - "auth_invalid_client_id" : "sim_invalid_client_id", - "auth_invalid_redirect_uri": "sim_invalid_redirect_uri", - "auth_invalid_scope" : "sim_invalid_scope" + "auth_invalid_client_id" : /error_description=Simulated\+invalid\+client_id\+parameter\+error/, + "auth_invalid_scope" : /error_description=Simulated\+invalid\+scope\+error/ } - const requiredAuthorizeParams = [ - "redirect_uri", - "response_type", - // "client_id", - // "scope", - // "state" - ] - - // checks for required params - // ------------------------------------------------------------- - for (const name of requiredAuthorizeParams) { - - // If redirect_uri is missing we reply with 400 (tested below) - // If anything else is missing we redirect and pass an error param - if (name !== "redirect_uri") { - it(`requires "${name}" param`, () => { - const query = { ...fullQuery }; - delete query[name] + it(`requires "response_type" param`, () => { + const query = { ...fullQuery }; + delete query.response_type + return request(app) + .get(url.pathname) + .query(query) + .expect(302) + .expect("location", /error_description=Missing\+response_type\+parameter/) + .expect("location", /state=x/) + }); - return request(app) - .get(url.pathname) - .query(query) - .expect(302) - .expect(function(res) { - const loc = res.get("location"); - if (!loc || loc.indexOf(`error=missing_parameter`) == -1) { - throw new Error(`No error passed to the redirect ${loc}`) - } - }) - .expect(function(res) { - const loc = res.get("location"); - if (!loc || loc.indexOf("state=x") == -1) { - throw new Error(`No state passed to the redirect ${loc}`) - } - }); - }); - } - } + it(`requires "redirect_uri" param`, () => { + const query = { ...fullQuery }; + delete query.redirect_uri + return request(app) + .get(url.pathname) + .query(query) + .expect(400) + .expect({ error: "invalid_request", error_description: "Missing redirect_uri parameter" }) + }); // validates the redirect_uri parameter // ------------------------------------------------------------- @@ -556,11 +616,28 @@ for(const FHIR_VERSION in TESTED_FHIR_SERVERS) { return request(app) .get(url.pathname) .query({ ...fullQuery, redirect_uri: "x" }) - .expect(/^Invalid redirect_uri parameter/) + .expect({ error: "invalid_request", error_description: "Invalid redirect_uri parameter 'x' (must be full URL)" }) .expect(400); }); - // simulated errors + it (`can simulate invalid_redirect_uri error`, () => { + + const url = buildUrl({ + fhir: FHIR_VERSION, + path: "/auth/authorize", + sim: { + auth_error: "auth_invalid_redirect_uri" + } + }); + + return request(app) + .get(url.pathname) + .query(fullQuery) + .expect(400) + .expect({error:"invalid_request",error_description:"Simulated invalid redirect_uri parameter error"}) + }); + + // other simulated errors // ------------------------------------------------------------- Object.keys(authErrors).forEach(errorName => { it (`can simulate "${errorName}" error via sim`, () => { @@ -577,15 +654,7 @@ for(const FHIR_VERSION in TESTED_FHIR_SERVERS) { .get(url.pathname) .query(fullQuery) .expect(302) - .expect(function(res) { - const loc = res.get("location"); - if (!loc || loc.indexOf(`error=${authErrors[errorName]}`) == -1) { - throw new Error(`No error passed to the redirect ${loc}`) - } - if (!loc || loc.indexOf("state=x") == -1) { - throw new Error(`No state passed to the redirect ${loc}`) - } - }); + .expect("location", authErrors[errorName]) }); it (`can simulate "${errorName}" error via launch param`, () => { @@ -605,15 +674,16 @@ for(const FHIR_VERSION in TESTED_FHIR_SERVERS) { .get(url.pathname) .query(url.searchParams.toString()) .expect(302) - .expect(function(res) { - const loc = res.get("location"); - if (!loc || loc.indexOf(`error=${authErrors[errorName]}`) == -1) { - throw new Error(`No error passed to the redirect ${loc}`) - } - if (!loc || loc.indexOf("state=x") == -1) { - throw new Error(`No state passed to the redirect ${loc}`) - } - }); + .expect("location", authErrors[errorName]) + // .expect(function(res) { + // const loc = res.get("location"); + // if (!loc || loc.indexOf(`error=${authErrors[errorName]}`) == -1) { + // throw new Error(`No error passed to the redirect ${loc}`) + // } + // if (!loc || loc.indexOf("state=x") == -1) { + // throw new Error(`No state passed to the redirect ${loc}`) + // } + // }); }); }) @@ -624,16 +694,7 @@ for(const FHIR_VERSION in TESTED_FHIR_SERVERS) { .get(url.pathname) .query({ ...fullQuery, aud: "whatever" }) .expect(302) - .expect(function(res) { - const loc = res.get("location"); - if (!loc) { - throw new Error(`No redirect`) - } - let url = Url.parse(loc, true); - if (url.query.error != "bad_audience") { - throw new Error(`Wrong redirect ${loc}`) - } - }); + .expect("location", /error_description=Bad\+audience\+value/) }); // can show encounter picker @@ -687,11 +748,136 @@ for(const FHIR_VERSION in TESTED_FHIR_SERVERS) { launch: { launch_pt: 1 } }); }); + + it ("shows patient picker if needed", () => { + const url = buildUrl({ + fhir: FHIR_VERSION, + path: "/auth/authorize", + query: { + ...fullQuery, + scope: "launch", + aud: config.baseUrl + `/v/${FHIR_VERSION}/fhir`, + launch: { + launch_ehr: 1 + } + } + }); + + return request(app) + .get(url.pathname) + .query(url.searchParams.toString()) + .expect(302) + .expect("location", /\/picker\?/) + }); + + it ("shows patient picker if needed", () => { + const url = buildUrl({ + fhir: FHIR_VERSION, + path: "/auth/authorize", + query: { + ...fullQuery, + scope: "launch", + aud: config.baseUrl + `/v/${FHIR_VERSION}/fhir`, + launch: { + launch_ehr: 1 + } + } + }); + + return request(app) + .get(url.pathname) + .query(url.searchParams.toString()) + .expect(302) + .expect("location", /\/picker\?/) + }); + + it ("shows patient picker if multiple patients are pre-selected", () => { + const url = buildUrl({ + fhir: FHIR_VERSION, + path: "/auth/authorize", + query: { + ...fullQuery, + scope: "launch/patient", + aud: config.baseUrl + `/v/${FHIR_VERSION}/fhir`, + launch: { + launch_prov: 1 + } + } + }); + + return request(app) + .get(url.pathname) + .query(url.searchParams.toString()) + .expect(302) + .expect("location", /\/picker\?/) + }); + + it ("shows patient picker if needed in CDS launch", () => { + const url = buildUrl({ + fhir: FHIR_VERSION, + path: "/auth/authorize", + query: { + ...fullQuery, + scope: "launch/patient", + aud: config.baseUrl + `/v/${FHIR_VERSION}/fhir`, + launch: { + launch_cds: 1 + } + } + }); + + return request(app) + .get(url.pathname) + .query(url.searchParams.toString()) + .expect(302) + .expect("location", /\/picker\?/) + }); + + it ("does not show patient picker if scopes do not require it", () => { + const url = buildUrl({ + fhir: FHIR_VERSION, + path: "/auth/authorize", + query: { + ...fullQuery, + scope: "whatever", + aud: config.baseUrl + `/v/${FHIR_VERSION}/fhir` + } + }); + + return request(app) + .get(url.pathname) + .query(url.searchParams.toString()) + .expect(res => expect(!res.header.location || !res.header.location.match(/\/picker\?/)).to.equal(true)) + }); + + it ("shows provider login screen if needed", () => { + const url = buildUrl({ + fhir: FHIR_VERSION, + path: "/auth/authorize", + query: { + ...fullQuery, + scope: "launch openid profile", + aud: config.baseUrl + `/v/${FHIR_VERSION}/fhir`, + launch: { + patient: "X", + encounter: "x", + launch_ehr: 1 + } + } + }); + + return request(app) + .get(url.pathname) + .query(url.searchParams.toString()) + .expect(302) + .expect("location", /\/login\?/) + }); + }) describe('token', function() { - it("can simulate sim_expired_refresh_token", async () => { + it("can simulate expired refresh tokens", async () => { const { code } = await SMART.getAuthCode({ scope: "offline_access", @@ -718,8 +904,8 @@ for(const FHIR_VERSION in TESTED_FHIR_SERVERS) { .post(buildUrl({ fhir: FHIR_VERSION, path: "auth/token" }).pathname) .type("form") .send({ grant_type: "refresh_token", refresh_token: tokenResponse.refresh_token }) - .expect('Content-Type', /text/) - .expect(401, config.errors.sim_expired_refresh_token); + .expect('Content-Type', /json/) + .expect(403, {error:"invalid_grant",error_description:"Expired refresh token"}); }); it("provides id_token", async () => { @@ -785,7 +971,7 @@ for(const FHIR_VERSION in TESTED_FHIR_SERVERS) { auth_error: "auth_invalid_client_secret", refresh_token: token }) - .expect("Simulated invalid client secret error") + .expect({error:"invalid_client",error_description:"Simulated invalid client secret error"}) .expect(401); }); @@ -798,7 +984,7 @@ for(const FHIR_VERSION in TESTED_FHIR_SERVERS) { grant_type: "refresh_token", refresh_token: token }) - .expect("The authorization header 'Basic' cannot be empty") + .expect({error:"invalid_request",error_description:"The authorization header 'Basic' cannot be empty"}) .expect(401); }); @@ -811,11 +997,11 @@ for(const FHIR_VERSION in TESTED_FHIR_SERVERS) { grant_type: "refresh_token", refresh_token: token }) - .expect(/^Bad authorization header/) + .expect({ error: "invalid_request", error_description:"Bad authorization header 'Basic bXktYXB': The decoded header must contain '{client_id}:{client_secret}'" }) .expect(401); }); - it ("can simulate sim_invalid_token", () => { + it ("can simulate invalid token errors", () => { return request(app) .post(buildUrl({ fhir: FHIR_VERSION, path: "/auth/token" }).pathname) .type('form') @@ -824,7 +1010,7 @@ for(const FHIR_VERSION in TESTED_FHIR_SERVERS) { grant_type: "authorization_code", code: jwt.sign({ auth_error:"token_invalid_token" }, config.jwtSecret) }) - .expect("Simulated invalid token error") + .expect({ error: "invalid_client", error_description: "Invalid token!" }) .expect(401); }); }) @@ -844,6 +1030,41 @@ for(const FHIR_VERSION in TESTED_FHIR_SERVERS) { }) }) + it("requires authorization", async () => { + await request(app) + .post(buildUrl({ fhir: FHIR_VERSION, path: "auth/introspect" }).pathname) + .expect(401, "Authorization is required") + }) + + it("rejects invalid token", async () => { + await request(app) + .post(buildUrl({ fhir: FHIR_VERSION, path: "auth/introspect" }).pathname) + .set('Authorization', `Bearer invalidToken`) + .expect(401) + }) + + it("requires token in the payload", async () => { + const { code } = await SMART.getAuthCode({ + scope: "offline_access", + launch: { + launch_pt : 1, + skip_login: 1, + skip_auth : 1, + patient : "abc", + encounter : "bcd", + } + }); + + const { access_token } = await SMART.getAccessToken(code); + + await request(app) + .post(buildUrl({ fhir: FHIR_VERSION, path: "auth/introspect" }).pathname) + .set('Authorization', `Bearer ${access_token}`) + .set('Accept', 'application/json') + .set('Content-Type', 'application/x-www-form-urlencoded') + .expect(400, "No token provided") + }) + it("can introspect an access token", async () => { const { code } = await SMART.getAuthCode({ scope: "offline_access", @@ -962,7 +1183,7 @@ for(const FHIR_VERSION in TESTED_FHIR_SERVERS) { return request(app) .post(buildUrl({ fhir: FHIR_VERSION, path: "/auth/register" }).pathname) .send({}) - .expect(401, "Invalid request content-type header (must be 'application/x-www-form-urlencoded')") + .expect(400, { error: "invalid_request", error_description: "Invalid request content-type header (must be 'application/x-www-form-urlencoded')" }) }); it ("requires 'iss' parameter", () => { @@ -970,7 +1191,7 @@ for(const FHIR_VERSION in TESTED_FHIR_SERVERS) { .post(buildUrl({ fhir: FHIR_VERSION, path: "/auth/register" }).pathname) .type("form") .send({}) - .expect(400, "Missing iss parameter") + .expect(400, { error: "invalid_request", error_description: 'Missing parameter "iss"' }) }); it ("requires 'pub_key' parameter", () => { @@ -978,7 +1199,7 @@ for(const FHIR_VERSION in TESTED_FHIR_SERVERS) { .post(buildUrl({ fhir: FHIR_VERSION, path: "/auth/register" }).pathname) .type("form") .send({ iss: "whatever" }) - .expect(400, "Missing pub_key parameter") + .expect(400, { error: "invalid_request", error_description: 'Missing parameter "pub_key"' }) }); it ("validates the 'dur' parameter", () => { @@ -988,7 +1209,7 @@ for(const FHIR_VERSION in TESTED_FHIR_SERVERS) { .post(buildUrl({ fhir: FHIR_VERSION, path: "/auth/register" }).pathname) .type("form") .send({ iss: "whatever", pub_key: "abc", dur }) - .expect(400, "Invalid dur parameter"); + .expect(400, { error: "invalid_request", error_description: 'Invalid parameter "dur"' }); }) ) }); diff --git a/test/unit.js b/test/unit.js new file mode 100644 index 00000000..1a228e25 --- /dev/null +++ b/test/unit.js @@ -0,0 +1,55 @@ +// const request = require('supertest'); +// const jwt = require("jsonwebtoken"); +// const Url = require("url"); +// const jwkToPem = require("jwk-to-pem"); +// const crypto = require("crypto"); +const { expect } = require("chai"); +// const app = require("../src/index.js"); +// const config = require("../src/config"); +// const Codec = require("../static/codec.js"); +const Lib = require("../src/lib"); + + + +it('whitelist throws if no match and no default value', () => { + expect(() => Lib.whitelist([1,2,3], 4)).to.throw( + 'The value "4" is not allowed. Must be one of 1, 2, 3.' + ) +}) + +it('whitelist returns the default value if no match', () => { + expect(Lib.whitelist([1,2,3], 4, 5)).to.equal(5) +}) + +it('whitelist returns the value if match', () => { + expect(Lib.whitelist([1,2,3], 2)).to.equal(2) +}) + +it('assert formats messages', () => { + expect(() => Lib.assert(false, "a %s b %d c", 2, 3)).to.throw(/a 2 b 3 c/) +}) + +it('assert formats messages via function', () => { + expect(() => Lib.assert(() => { throw new Error("test-error") }, "a %s b")).to.throw(/a test-error b/) +}) + +it('assert.http works via function', () => { + expect(() => Lib.assert.http(() => { throw new Error("test-error") }, 403, "a %s b")).to.throw(Lib.HTTPError, "test-error") +}) + +it ('assert.fail works with Error', () => { + expect(() => Lib.assert.fail(new Error("test-error"))).to.throw("test-error") +}) + +it ('assert.fail works with HTTPError', () => { + expect(() => Lib.assert.fail(new Lib.HTTPError(405, "test-error"))).to.throw(Lib.HTTPError, "test-error") +}) + +it ('assert.fail works with OperationOutcome', () => { + expect(() => Lib.assert.fail({ type: "OperationOutcome", code: 404, msg: "test-error", error: "test" })).to.throw(/test-error/) +}) + +it ('assert.fail throws HTTPError by default', () => { + expect(() => Lib.assert.fail({ code: 404, msg: "test-error", error: "test" })).to.throw(Lib.HTTPError, "test-error") +}) +