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")
+})
+