| ID | Title | Duration (ms) |
|---|---|---|
| 1 | Client constructor throws if no state is given | 11 |
| 2 | Client request the url defaults to the serverUrl | 350 |
| 3 | Client request accepts url string | 545 |
| 4 | Client request accepts options object | 470 |
| 5 | Client request handles OperationOutcome errors | 187 |
| 6 | Client request handles other errors | 47 |
| 7 | Client request rejects invalid access tokens | 183 |
| 8 | Client request tries to use the refresh token on 401 | 253 |
| 9 | Client refresh rejects with no state.tokenResponse | 0 |
| 10 | Client refresh rejects with no state.tokenResponse.refresh_token | 1 |
| 11 | Client refresh deletes invalid refresh tokens | 124 |
| 12 | Client refresh handles other errors | 0 |
| 13 | Client refresh works as expected | 1003 |
| 14 | express adapter getStorage returns null if session is empty | 1 |
| 15 | express adapter getStorage can be set as option | 0 |
| 16 | express adapter getStorage uses session storage by default | 1 |
| 17 | express adapter authorize using options.serverUrl and an open server | 538 |
| 18 | express adapter authorize using options.serverUrl and an protected server | 521 |
| 19 | express adapter authorize using query.serverUrl and an open server | 536 |
| 20 | express adapter authorize using query.serverUrl and an protected server | 495 |
| 21 | express adapter authorize using query.iss and query.launch and an open server | 551 |
| 22 | express adapter authorize using query.iss and query.launch and protected server | 502 |
| 23 | express adapter authorize calls next if not enough arguments | 1 |
| 24 | express adapter completeAuth ends with error and error_description if provided | 1 |
| 25 | express adapter completeAuth handles internal errors | 1 |
| 26 | express adapter completeAuth handles errors | 128 |
| 27 | express adapter completeAuth calls next if not enough arguments provided | 0 |
| 28 | express adapter completeAuth works as expected | 883 |
| 29 | express adapter refreshAuth ends with error message if no client can be constructed | 1 |
| 30 | express adapter refreshAuth handles errors | 0 |
| 31 | hapi adapter getStorage returns null if session is empty | 0 |
| 32 | hapi adapter getStorage can be set as option | 1 |
| 33 | hapi adapter getStorage uses session storage by default | 0 |
| 34 | hapi adapter authorize using options.serverUrl and an open server | 525 |
| 35 | hapi adapter authorize using options.serverUrl and an protected server | 530 |
| 36 | hapi adapter authorize using query.serverUrl and an open server | 571 |
| 37 | hapi adapter authorize using query.serverUrl and an protected server | 578 |
| 38 | hapi adapter authorize using query.iss and query.launch and an open server | 534 |
| 39 | hapi adapter authorize using query.iss and query.launch and protected server | 615 |
| 40 | hapi adapter authorize calls next if not enough arguments | 0 |
| 41 | hapi adapter completeAuth ends with error and error_description if provided | 1 |
| 42 | hapi adapter completeAuth handles internal errors | 1 |
| 43 | hapi adapter completeAuth calls next if not enough arguments provided | 0 |
| 44 | hapi adapter completeAuth works as expected | 978 |
| 45 | hapi adapter refreshAuth ends with error message if no client can be constructed | 1 |
| 46 | hapi adapter refreshAuth handles errors | 1 |
| 47 | lib getPath returns the first arg if no path | 0 |
| 48 | lib getPath returns the first arg for empty path | 0 |
| 49 | lib getPath works as expected | 1 |
| 50 | lib printf works as expected | 1 |
| 51 | lib getErrorText works as expected | 1 |
| 52 | lib HttpError constructor The message defaults to 'Unknown error' | 1 |
| 53 | lib HttpError constructor The httpCode defaults to 500 | 0 |
| 54 | lib HttpError constructor The httpCode can be set | 0 |
| 55 | lib HttpError constructor Variable substitution works | 0 |
| 56 | lib getSecurityExtensions Works without arguments | 3 |
| 57 | lib getSecurityExtensions Works with HSPC | 1228 |
| 58 | lib getSecurityExtensions Works with R3 - Protected | 500 |
| 59 | lib getSecurityExtensions Works with R3 - Open | 697 |
| 60 | lib resolveUrl works as expected | 3 |
| 61 | lib buildAuthorizeUrl Rejects if no serverUrl can be found | 1 |
| 62 | lib buildAuthorizeUrl Works with open servers | 2 |
| 63 | lib buildAuthorizeUrl Works with query.fhirServiceUrl parameter | 2 |
| 64 | lib buildAuthorizeUrl Works with options.serverUrl parameter | 1 |
| 65 | lib buildAuthorizeUrl requires 'launch' param if 'iss' is used | 1 |
| 66 | lib buildAuthorizeUrl clears previous state | 4 |
| 67 | lib buildAuthorizeUrl Works with clientSecret | 2 |
| 68 | lib buildAuthorizeUrl Works with protected servers | 494 |
| 69 | lib buildAuthorizeUrl Works with protected servers standalone | 500 |
| 70 | lib authorize works as expected | 498 |
| 71 | lib buildTokenRequest rejects if no state is found | 1 |
| 72 | lib buildTokenRequest rejects if there is no redirectUri in state | 14 |
| 73 | lib buildTokenRequest rejects if there is no tokenUri in state | 1 |
| 74 | lib buildTokenRequest rejects if there is no clientId in state | 1 |
| 75 | lib buildTokenRequest works as expected | 1 |
| 76 | lib buildTokenRequest works with clientSecret | 1 |
| 77 | lib handleTokenError works as expected | 1 |
| 78 | lib handleTokenError message can be set | 0 |
| 79 | lib handleTokenError catches 'error' property in response payloads | 1 |
| 80 | lib handleTokenError catches 'error_description' property in response payloads | 1 |
| 81 | lib handleTokenError catches errors from string payloads | 0 |
| 82 | lib completeAuth handles errors | 130 |
| 83 | lib completeAuth works as expected | 748 |
| Line | Lint | Hits | Source |
|---|---|---|---|
| 1 | "use strict"; | ||
| 2 | 1 | Object.defineProperty(exports, "__esModule", { value: true }); | |
| 3 | 1 | const axios_1 = require("axios"); | |
| 4 | 1 | const querystring = require("querystring"); | |
| 5 | 1 | const _debug = require("debug"); | |
| 6 | 1 | const lib_1 = require("./lib"); | |
| 7 | 1 | const debug = _debug("smart"); | |
| 8 | /** | ||
| 9 | * A SMART Client instance will simplify some tasks for you. It will authorize | ||
| 10 | * requests automatically, use refresh tokens, handle errors and so on. | ||
| 11 | */ | ||
| 12 | class Client { | ||
| 13 | constructor(state) { | ||
| 14 | // This might happen if the state have been lost (for example the server | ||
| 15 | // was restarted and a memory storage was used). | ||
| 16 | if (!state) { | ||
| 17 | 1 | throw new Error("No state provided to the client"); | |
| 18 | } | ||
| 19 | 23 | this.state = state; | |
| 20 | } | ||
| 21 | /** | ||
| 22 | * Allows you to do the following: | ||
| 23 | * 1. Use relative URLs (treat them as relative to the "serverUrl" option) | ||
| 24 | * 2. Automatically authorize requests with your accessToken (if any) | ||
| 25 | * 3. Automatically re-authorize using the refreshToken (if available) | ||
| 26 | * 4. Automatically parse error operation outcomes and turn them into | ||
| 27 | * JavaScript Error objects with which the resulting promises are rejected | ||
| 28 | * @param {object|string} options URL or axios request options | ||
| 29 | */ | ||
| 30 | async request(options = {}) { | ||
| 31 | if (typeof options == "string") { | ||
| 32 | 6 | options = { url: options }; | |
| 33 | } | ||
| 34 | 9 | options.baseURL = this.state.serverUrl.replace(/\/?$/, "/"); | |
| 35 | 8 | const accessToken = lib_1.getPath(this.state, "tokenResponse.access_token"); | |
| 36 | 8 | if (accessToken) { | |
| 37 | 3 | options.headers = Object.assign({}, options.headers, { Authorization: `Bearer ${accessToken}` }); | |
| 38 | } | ||
| 39 | 8 | return axios_1.default(options).catch(error => { | |
| 40 | // The request was made and the server responded with a | ||
| 41 | // status code that falls out of the range of 2xx | ||
| 42 | if (error.response) { | ||
| 43 | 5 | if (error.response.status == 401) { | |
| 44 | 3 | debug("401 received"); | |
| 45 | 3 | if (lib_1.getPath(this.state, "tokenResponse.refresh_token")) { | |
| 46 | 1 | debug("Refreshing using the refresh token"); | |
| 47 | 1 | return this.refresh().then(() => this.request(options)); | |
| 48 | } | ||
| 49 | } | ||
| 50 | 4 | const resourceType = lib_1.getPath(error, "response.data.resourceType"); | |
| 51 | 4 | const issues = lib_1.getPath(error, "response.data.issue"); | |
| 52 | 4 | const body = error.response.data; | |
| 53 | 4 | if (resourceType == "OperationOutcome" && issues.length) { | |
| 54 | 2 | debug("OperationOutcome error response detected"); | |
| 55 | 2 | const errors = body.issue.map((o) => `${o.severity} ${o.code} ${o.diagnostics}`); | |
| 56 | 2 | throw new Error(errors.join("\n")); | |
| 57 | } | ||
| 58 | 2 | throw error; | |
| 59 | } | ||
| 60 | // The request was made but no response was received | ||
| 61 | // "error.request" is an instance of http.ClientRequest | ||
| 62 | 1 | throw new Error(lib_1.getErrorText("no_fhir_response")); | |
| 63 | }); | ||
| 64 | } | ||
| 65 | /** | ||
| 66 | * Use the refresh token to obtain new access token. | ||
| 67 | * If the refresh token is expired it will be deleted from the state, so | ||
| 68 | * that we don't enter into loops trying to re-authorize. | ||
| 69 | */ | ||
| 70 | async refresh() { | ||
| 71 | if (!this.state.tokenResponse || !this.state.tokenResponse.refresh_token) { | ||
| 72 | 4 | throw new Error("Trying to refresh but there is no refresh token"); | |
| 73 | } | ||
| 74 | 5 | const refreshToken = lib_1.getPath(this.state, "tokenResponse.refresh_token"); | |
| 75 | 5 | return axios_1.default({ | |
| 76 | method: "POST", | ||
| 77 | url: this.state.tokenUri, | ||
| 78 | headers: { | ||
| 79 | "content-type": "application/x-www-form-urlencoded" | ||
| 80 | }, | ||
| 81 | data: querystring.stringify({ | ||
| 82 | grant_type: "refresh_token", | ||
| 83 | refresh_token: refreshToken | ||
| 84 | }) | ||
| 85 | }).then(({ data }) => { | ||
| 86 | this.state.tokenResponse = Object.assign({}, this.state.tokenResponse, data); | ||
| 87 | 3 | return data; | |
| 88 | }) | ||
| 89 | .catch(error => { | ||
| 90 | if (error.response && error.response.status == 401) { | ||
| 91 | 1 | debug("401 received - refresh token expired or invalid"); | |
| 92 | 1 | debug("Deleting the expired or invalid refresh token"); | |
| 93 | 1 | delete this.state.tokenResponse.refresh_token; | |
| 94 | } | ||
| 95 | 2 | throw error; | |
| 96 | }); | ||
| 97 | } | ||
| 98 | } | ||
| 99 | 1 | exports.default = Client; | |
| 100 |
| Line | Lint | Hits | Source |
|---|---|---|---|
| 1 | "use strict"; | ||
| 2 | 1 | Object.defineProperty(exports, "__esModule", { value: true }); | |
| 3 | 1 | exports.default = { | |
| 4 | "": "Unknown error", | ||
| 5 | unknown_error: "Unknown error", | ||
| 6 | missing_url_parameter: `Missing url parameter "%s"`, | ||
| 7 | missing_state_by_id: `No state found using the given id: "%s".`, | ||
| 8 | missing_state_redirectUri: "Missing state.redirectUri", | ||
| 9 | missing_state_tokenUri: "Missing state.tokenUri", | ||
| 10 | missing_state_clientId: "Missing state.clientId", | ||
| 11 | no_fhir_response: "No response received from the FHIR server", | ||
| 12 | no_server_url_provided: "Cannot detect which FHIR server to launch against. " + | ||
| 13 | "For EHR launch call your endpoint with 'launch' " + | ||
| 14 | "and 'iss' parameters. For standalone launch pass " + | ||
| 15 | "'fhirServiceUrl' parameter or set it as 'serverUrl'" + | ||
| 16 | "in your configuration." | ||
| 17 | }; | ||
| 18 |
| Line | Lint | Hits | Source |
|---|---|---|---|
| 1 | 1 | const smart = require("./lib"); | |
| 2 | |||
| 3 | |||
| 4 | 1 | module.exports = function(options) { | |
| 5 | const cfg = { getStorage: smart.getSessionStorage, ...options }; | ||
| 6 | |||
| 7 | // Return the Express-specific high-level API | ||
| 8 | 17 | return { | |
| 9 | |||
| 10 | /** | ||
| 11 | * Given a request object this function will return a Promise that will | ||
| 12 | * eventually be resolved with a Fhir Client object or with `null` if | ||
| 13 | * there is a problem. It will lookup the storage for stored SMART state. | ||
| 14 | * If such state is found, it will create and return a Fhir Client | ||
| 15 | * instance that knows how to use that (automatically authorize any | ||
| 16 | * requests made with `client.request`, use clientSecret and refresh | ||
| 17 | * tokens etc.). | ||
| 18 | * @param {Object} request | ||
| 19 | * @returns {Promise<Client|null>} | ||
| 20 | */ | ||
| 21 | async getClient(request) { | ||
| 22 | const storage = cfg.getStorage(request); | ||
| 23 | 7 | const id = await storage.get("smartId"); | |
| 24 | 7 | if (id) { | |
| 25 | 5 | const state = await storage.get(id); | |
| 26 | 5 | if (state) { | |
| 27 | 4 | return new smart.Client(state); | |
| 28 | } | ||
| 29 | } | ||
| 30 | 3 | return null; | |
| 31 | }, | ||
| 32 | |||
| 33 | /** | ||
| 34 | * If the fhir server base can be determined (from query.iss or | ||
| 35 | * query.fhirServiceUrl or options.serverUrl), then the user will be | ||
| 36 | * redirected to the authorization endpoint (or to your redirectUri if | ||
| 37 | * it happens to be an open server). Otherwise, the `next` callback | ||
| 38 | * will be called. | ||
| 39 | * @param {Object} req | ||
| 40 | * @param {Object} res | ||
| 41 | * @param {Function} next | ||
| 42 | */ | ||
| 43 | authorize(req, res, next) { | ||
| 44 | let { launch, iss, fhirServiceUrl } = req.query; | ||
| 45 | 8 | if ((launch && iss) || fhirServiceUrl || cfg.serverUrl) { | |
| 46 | 7 | return smart.authorize(req, res, cfg, cfg.getStorage(req)); | |
| 47 | } | ||
| 48 | 1 | next(); | |
| 49 | }, | ||
| 50 | |||
| 51 | /** | ||
| 52 | * The `authorize` function above will redirect the browser to the | ||
| 53 | * authorization endpoint of the SMART server. Depending on the requested | ||
| 54 | * scopes, the user might be asked to select a patient or practitioner, | ||
| 55 | * authorize the app and so on. Eventually, the user will be redirected | ||
| 56 | * back to your site at your `options.redirectUri`. At that point you | ||
| 57 | * should have received a code that needs to be exchanged for an access | ||
| 58 | * token. | ||
| 59 | * @param {Object} req | ||
| 60 | * @param {Object} res | ||
| 61 | * @param {Function} next | ||
| 62 | */ | ||
| 63 | completeAuth(req, res, next) { | ||
| 64 | let { code, state, error, error_description } = req.query; | ||
| 65 | 6 | if (error) { | |
| 66 | 2 | let msg = error + (error_description ? ":\n" + error_description : ""); | |
| 67 | 2 | return res.end(msg); | |
| 68 | } | ||
| 69 | 4 | if (code && state) { | |
| 70 | 3 | return smart.completeAuth(req, cfg.getStorage(req)) | |
| 71 | .then( | ||
| 72 | () => res.redirect(cfg.redirectUri), | ||
| 73 | e => { | ||
| 74 | res.writeHead(e.httpCode || 500, { 'Content-Type': 'text/plain' }); | ||
| 75 | 2 | res.end(e.message, "utf8"); | |
| 76 | } | ||
| 77 | ); | ||
| 78 | } | ||
| 79 | 1 | next(); | |
| 80 | }, | ||
| 81 | |||
| 82 | /** | ||
| 83 | * You don't typically need to use this because if you have a refresh | ||
| 84 | * token (which you should if your scope includes `offline_access`), | ||
| 85 | * then the request method will automatically use it when your access | ||
| 86 | * token expires. | ||
| 87 | * @param {Object} req | ||
| 88 | * @param {Object} res | ||
| 89 | * @param {Function} next | ||
| 90 | */ | ||
| 91 | async refreshAuth(req, res, next) { | ||
| 92 | const client = await this.getClient(req); | ||
| 93 | |||
| 94 | // Perhaps the server was restarted or lost it's session for some other reason | ||
| 95 | 3 | if (!client) { | |
| 96 | 1 | return res.end("No smart state found in session. Please re-authorize your app.") | |
| 97 | } | ||
| 98 | |||
| 99 | 2 | return client.refresh().then(() => next(), error => res.end(error.message)); | |
| 100 | } | ||
| 101 | } | ||
| 102 | }; | ||
| 103 |
| Line | Lint | Hits | Source |
|---|---|---|---|
| 1 | 1 | const smart = require("./lib"); | |
| 2 | |||
| 3 | |||
| 4 | 1 | module.exports = function(options) { | |
| 5 | const cfg = { getStorage: smart.getSessionStorage, ...options }; | ||
| 6 | |||
| 7 | // Return the HAPI-specific high-level API | ||
| 8 | 16 | return { | |
| 9 | |||
| 10 | /** | ||
| 11 | * Given a request object this function will return a Promise that will | ||
| 12 | * eventually be resolved with a Fhir Client object or with `null` if | ||
| 13 | * there is a problem. It will lookup the storage for stored SMART state. | ||
| 14 | * If such state is found, it will create and return a Fhir Client | ||
| 15 | * instance that knows how to use that (automatically authorize any | ||
| 16 | * requests made with `client.request`, use clientSecret and refresh | ||
| 17 | * tokens etc.). | ||
| 18 | * @param {Object} request | ||
| 19 | * @returns {Promise<Client|null>} | ||
| 20 | */ | ||
| 21 | async getClient(request) { | ||
| 22 | const storage = cfg.getStorage(request); | ||
| 23 | 7 | const id = await storage.get("smartId"); | |
| 24 | 7 | if (id) { | |
| 25 | 5 | const state = await storage.get(id); | |
| 26 | 5 | if (state) { | |
| 27 | 4 | return new smart.Client(state); | |
| 28 | } | ||
| 29 | } | ||
| 30 | 3 | return null; | |
| 31 | }, | ||
| 32 | |||
| 33 | /** | ||
| 34 | * If the fhir server base can be determined (from query.iss or | ||
| 35 | * query.fhirServiceUrl or options.serverUrl), then the user will be | ||
| 36 | * redirected to the authorization endpoint (or to your redirectUri if | ||
| 37 | * it happens to be an open server). Otherwise, the `next` callback | ||
| 38 | * will be called. | ||
| 39 | * @param {Object} req | ||
| 40 | * @param {Object} h | ||
| 41 | * @returns {Promise<any>|Symbol} | ||
| 42 | */ | ||
| 43 | authorize(req, h) { | ||
| 44 | let { launch, iss, fhirServiceUrl } = req.query; | ||
| 45 | 8 | if ((launch && iss) || fhirServiceUrl || cfg.serverUrl) { | |
| 46 | 7 | return smart.buildAuthorizeUrl(req.raw.req, cfg, cfg.getStorage(req)) | |
| 47 | .then(url => h.redirect(url)); | ||
| 48 | } | ||
| 49 | 1 | return h.continue; | |
| 50 | }, | ||
| 51 | |||
| 52 | /** | ||
| 53 | * The `authorize` function above will redirect the browser to the | ||
| 54 | * authorization endpoint of the SMART server. Depending on the requested | ||
| 55 | * scopes, the user might be asked to select a patient or practitioner, | ||
| 56 | * authorize the app and so on. Eventually, the user will be redirected | ||
| 57 | * back to your site at your `options.redirectUri`. At that point you | ||
| 58 | * should have received a code that needs to be exchanged for an access | ||
| 59 | * token. | ||
| 60 | * @param {Object} req | ||
| 61 | * @param {Object} h | ||
| 62 | * @returns {Promise<any>|Symbol} | ||
| 63 | */ | ||
| 64 | completeAuth(req, h) { | ||
| 65 | let { code, state, error, error_description } = req.query; | ||
| 66 | 5 | if (error) { | |
| 67 | 2 | throw new Error(error + (error_description ? ":\n" + error_description : "")); | |
| 68 | } | ||
| 69 | 3 | if (code && state) { | |
| 70 | 2 | return smart.completeAuth(req.raw.req, cfg.getStorage(req)) | |
| 71 | .then(() => h.redirect(cfg.redirectUri)); | ||
| 72 | } | ||
| 73 | 1 | return h.continue; | |
| 74 | }, | ||
| 75 | |||
| 76 | /** | ||
| 77 | * You don't typically need to use this because if you have a refresh | ||
| 78 | * token (which you should if your scope includes `offline_access`), | ||
| 79 | * then the request method will automatically use it when your access | ||
| 80 | * token expires. | ||
| 81 | * @param {Object} request | ||
| 82 | * @param {Object} h | ||
| 83 | */ | ||
| 84 | async refreshAuth(request, h) { | ||
| 85 | const client = await this.getClient(request); | ||
| 86 | |||
| 87 | // Perhaps the server was restarted or lost it's session for some other reason | ||
| 88 | 3 | if (!client) { | |
| 89 | 1 | throw new Error("No smart state found in session. Please re-authorize your app."); | |
| 90 | } | ||
| 91 | |||
| 92 | 2 | return client.refresh().then(() => h.continue, error => { throw error }); | |
| 93 | } | ||
| 94 | }; | ||
| 95 | } | ||
| 96 | |||
| 97 |
| Line | Lint | Hits | Source |
|---|---|---|---|
| 1 | "use strict"; | ||
| 2 | 1 | Object.defineProperty(exports, "__esModule", { value: true }); | |
| 3 | 1 | const axios_1 = require("axios"); | |
| 4 | 1 | const Crypto = require("crypto"); | |
| 5 | 1 | const Url = require("url"); | |
| 6 | 1 | const qs = require("querystring"); | |
| 7 | 1 | const _debug = require("debug"); | |
| 8 | 1 | const errors_1 = require("./errors"); | |
| 9 | 1 | const client_1 = require("./client"); | |
| 10 | 1 | exports.Client = client_1.default; | |
| 11 | 1 | const debug = _debug("smart"); | |
| 12 | /** | ||
| 13 | * Walks thru an object (or array) and returns the value found at the | ||
| 14 | * provided path. This function is very simple so it intentionally does not | ||
| 15 | * support any argument polymorphism, meaning that the path can only be a | ||
| 16 | * dot-separated string. If the path is invalid returns undefined. | ||
| 17 | * @param {Object} obj The object (or Array) to walk through | ||
| 18 | * @param {String} path The path (eg. "a.b.4.c") | ||
| 19 | * @returns {*} Whatever is found in the path or undefined | ||
| 20 | */ | ||
| 21 | function getPath(obj, path = "") { | ||
| 22 | 63 | path = path.trim(); | |
| 23 | 63 | if (!path) { | |
| 24 | 2 | return obj; | |
| 25 | } | ||
| 26 | 61 | return path.split(".").reduce((out, key) => out ? out[key] : undefined, obj); | |
| 27 | } | ||
| 28 | 1 | exports.getPath = getPath; | |
| 29 | /** | ||
| 30 | * Simplified version of printf. Just replaces all the occurrences of "%s" with | ||
| 31 | * whatever is supplied in the rest of the arguments. If no argument is supplied | ||
| 32 | * the "%s" token is replaced with an empty string. | ||
| 33 | * @param {String} s The string to format | ||
| 34 | * @param {*} ... The rest of the arguments are used for the replacements | ||
| 35 | * @return {String} | ||
| 36 | */ | ||
| 37 | function printf(s, ...args) { | ||
| 38 | 27 | const l = args.length; | |
| 39 | 27 | let i = 0; | |
| 40 | 27 | return String(s || "").replace(/(%s)/g, () => i >= l ? "" : args[i++]); | |
| 41 | } | ||
| 42 | 1 | exports.printf = printf; | |
| 43 | /** | ||
| 44 | * Returns the error message for the given error name, replacing any "%s" | ||
| 45 | * occurrences with the rest of the given arguments | ||
| 46 | * @param name The error name | ||
| 47 | * @param rest Any other arguments passed to printf | ||
| 48 | */ | ||
| 49 | function getErrorText(name = "", ...rest) { | ||
| 50 | 22 | return printf(errors_1.default[name], ...rest); | |
| 51 | } | ||
| 52 | 1 | exports.getErrorText = getErrorText; | |
| 53 | /** | ||
| 54 | * For custom error objects that also have an httpCode property | ||
| 55 | */ | ||
| 56 | class HttpError extends Error { | ||
| 57 | constructor(name = "", httpCode = 500, ...params) { | ||
| 58 | super(getErrorText(name, ...params) || name); | ||
| 59 | 15 | this.httpCode = httpCode; | |
| 60 | } | ||
| 61 | } | ||
| 62 | 1 | exports.HttpError = HttpError; | |
| 63 | /** | ||
| 64 | * Given a fhir server returns an object with it's Oauth security endpoints | ||
| 65 | * @param baseUrl Fhir server base URL | ||
| 66 | */ | ||
| 67 | async function getSecurityExtensions(baseUrl) { | ||
| 68 | 28 | const url = String(baseUrl || "").replace(/\/*$/, "/") + "metadata"; | |
| 69 | 28 | let metadata; | |
| 70 | 28 | try { | |
| 71 | 28 | metadata = await axios_1.default(url); | |
| 72 | } | ||
| 73 | catch (ex) { | ||
| 74 | 6 | metadata = {}; | |
| 75 | } | ||
| 76 | 28 | const nsUri = "http://fhir-registry.smarthealthit.org/StructureDefinition/oauth-uris"; | |
| 77 | 28 | const extensions = (getPath(metadata, "data.rest.0.security.extension") || []) | |
| 78 | .filter((e) => e.url === nsUri) | ||
| 79 | .map((o) => o.extension)[0]; | ||
| 80 | 28 | const out = { | |
| 81 | registrationUri: "", | ||
| 82 | authorizeUri: "", | ||
| 83 | tokenUri: "" | ||
| 84 | }; | ||
| 85 | 28 | if (extensions) { | |
| 86 | 15 | extensions.forEach((ext) => { | |
| 87 | if (ext.url === "register") { | ||
| 88 | 1 | out.registrationUri = ext.valueUri; | |
| 89 | } | ||
| 90 | 31 | if (ext.url === "authorize") { | |
| 91 | 15 | out.authorizeUri = ext.valueUri; | |
| 92 | } | ||
| 93 | 31 | if (ext.url === "token") { | |
| 94 | 15 | out.tokenUri = ext.valueUri; | |
| 95 | } | ||
| 96 | }); | ||
| 97 | } | ||
| 98 | 28 | return out; | |
| 99 | } | ||
| 100 | 1 | exports.getSecurityExtensions = getSecurityExtensions; | |
| 101 | /** | ||
| 102 | * Converts the given relative URL to absolute based on the request origin | ||
| 103 | * @param req The http request object | ||
| 104 | * @param url The url to convert | ||
| 105 | */ | ||
| 106 | function resolveUrl(req, url) { | ||
| 107 | 48 | const protocol = req.connection.encrypted ? "https" : "http"; | |
| 108 | 48 | return Url.resolve(protocol + "://" + req.headers.host, url); | |
| 109 | } | ||
| 110 | 1 | exports.resolveUrl = resolveUrl; | |
| 111 | /** | ||
| 112 | * First discovers the fhir server base URL from query.iis or query.fhirServiceUrl | ||
| 113 | * or options.serverUrl. Then compiles the proper authorization URL for that server. | ||
| 114 | * For open server that URL is the options.redirectUri so that we can skip the | ||
| 115 | * authorization part. | ||
| 116 | */ | ||
| 117 | async function buildAuthorizeUrl(req, options, storage) { | ||
| 118 | 26 | const url = Url.parse(req.url, true); | |
| 119 | 26 | const { launch, iss, fhirServiceUrl } = url.query; | |
| 120 | 26 | const serverUrl = iss || fhirServiceUrl || options.serverUrl || ""; | |
| 121 | 26 | if (iss && !launch) { | |
| 122 | 1 | throw new Error(getErrorText("missing_url_parameter", "launch")); | |
| 123 | } | ||
| 124 | 25 | if (!serverUrl) { | |
| 125 | 1 | debug("No serverUrl found. It must be specified as query.iss or " + | |
| 126 | "query.fhirServiceUrl or options.serverUrl (in that order)"); | ||
| 127 | 1 | throw new Error(getErrorText("no_server_url_provided")); | |
| 128 | } | ||
| 129 | 24 | debug(`Looking up the authorization endpoint for "${serverUrl}"`); | |
| 130 | 24 | const extensions = await getSecurityExtensions(serverUrl); | |
| 131 | 24 | debug(`Found security extensions: `, extensions); | |
| 132 | // Prepare the object that will be stored in the session | ||
| 133 | 24 | const state = Object.assign({ serverUrl: serverUrl, clientId: options.clientId, redirectUri: resolveUrl(req, options.redirectUri), scope: options.scope || "" }, extensions); | |
| 134 | 24 | if (options.clientSecret) { | |
| 135 | 1 | debug(`Adding clientSecret to the state`); | |
| 136 | 1 | state.clientSecret = options.clientSecret; | |
| 137 | } | ||
| 138 | // Create an unique key. We could use the session ID for that but we | ||
| 139 | // don't want it to be visible in the browser URL bar (as the state | ||
| 140 | // parameter) while redirecting. | ||
| 141 | 24 | const id = "smart-" + Crypto.randomBytes(8).toString("hex"); | |
| 142 | 24 | const oldId = await storage.get("smartId"); | |
| 143 | 24 | if (oldId) { | |
| 144 | 15 | debug(`Deleting previous state by id ("${oldId}")`); | |
| 145 | 15 | await storage.unset(oldId); | |
| 146 | } | ||
| 147 | 24 | debug(`Saving new state by id ("${id}")`); | |
| 148 | 24 | await storage.set(id, state); | |
| 149 | 24 | await storage.set("smartId", id); | |
| 150 | 24 | let redirectUrl = state.redirectUri; | |
| 151 | 24 | if (state.authorizeUri) { | |
| 152 | 13 | debug(`authorizeUri: ${state.authorizeUri}`); | |
| 153 | 13 | const params = [ | |
| 154 | "response_type=code", | ||
| 155 | "client_id=" + encodeURIComponent(state.clientId), | ||
| 156 | "scope=" + encodeURIComponent(state.scope), | ||
| 157 | "redirect_uri=" + encodeURIComponent(state.redirectUri), | ||
| 158 | "aud=" + encodeURIComponent(state.serverUrl), | ||
| 159 | "state=" + id | ||
| 160 | ]; | ||
| 161 | // also pass this in case of EHR launch | ||
| 162 | 13 | if (launch) { | |
| 163 | 6 | params.push("launch=" + encodeURIComponent(launch)); | |
| 164 | } | ||
| 165 | 13 | redirectUrl = state.authorizeUri + "?" + params.join("&"); | |
| 166 | } | ||
| 167 | 24 | debug(`Making authorize redirect to ${redirectUrl}`); | |
| 168 | 24 | return redirectUrl; | |
| 169 | } | ||
| 170 | 1 | exports.buildAuthorizeUrl = buildAuthorizeUrl; | |
| 171 | /** | ||
| 172 | * Calls the buildAuthorizeUrl function to construct the redirect URL and then | ||
| 173 | * just redirects to it. | ||
| 174 | */ | ||
| 175 | async function authorize(req, res, options, storage) { | ||
| 176 | 8 | debug(`Authorizing...`); | |
| 177 | 8 | const location = await buildAuthorizeUrl(req, options, storage); | |
| 178 | 8 | debug(`Making authorize redirect to ${location}`); | |
| 179 | 8 | res.writeHead(302, { location }); | |
| 180 | 8 | res.end(); | |
| 181 | } | ||
| 182 | 1 | exports.authorize = authorize; | |
| 183 | /** | ||
| 184 | * Builds the token request options for axios. Does not make the request, just | ||
| 185 | * creates it's configuration and returns it in a Promise. | ||
| 186 | * NOTE that this function has side effects because it modifies the storage | ||
| 187 | * contents. | ||
| 188 | * @param req | ||
| 189 | * @param storage | ||
| 190 | */ | ||
| 191 | async function buildTokenRequest(req, storage) { | ||
| 192 | 12 | const { state, code } = Url.parse(req.url, true).query; | |
| 193 | 12 | const cached = await storage.get(state); | |
| 194 | // First make sure we have such state and it contains everything we need | ||
| 195 | 12 | if (!cached) { | |
| 196 | 1 | debug(`No state found by the given id (${state})`); | |
| 197 | 1 | throw new HttpError("missing_state_by_id", 400, state); | |
| 198 | } | ||
| 199 | 11 | if (!cached.redirectUri) { | |
| 200 | 1 | debug(`Missing state.redirectUri`); | |
| 201 | 1 | throw new HttpError("missing_state_redirectUri", 400); | |
| 202 | } | ||
| 203 | 10 | if (!cached.tokenUri) { | |
| 204 | 1 | debug(`Missing state.tokenUri`); | |
| 205 | 1 | throw new HttpError("missing_state_tokenUri", 400); | |
| 206 | } | ||
| 207 | 9 | if (!cached.clientId) { | |
| 208 | 1 | debug(`Missing state.clientId`); | |
| 209 | 1 | throw new HttpError("missing_state_clientId", 400); | |
| 210 | } | ||
| 211 | 8 | const requestOptions = { | |
| 212 | method: "POST", | ||
| 213 | url: cached.tokenUri, | ||
| 214 | headers: { | ||
| 215 | "content-type": "application/x-www-form-urlencoded" | ||
| 216 | }, | ||
| 217 | data: { | ||
| 218 | code, | ||
| 219 | grant_type: "authorization_code", | ||
| 220 | redirect_uri: cached.redirectUri | ||
| 221 | } | ||
| 222 | }; | ||
| 223 | // For public apps, authentication is not possible (and thus not | ||
| 224 | // required), since the app cannot be trusted to protect a secret. | ||
| 225 | // For confidential apps, an Authorization header using HTTP Basic | ||
| 226 | // authentication is required, where the username is the app’s client_id | ||
| 227 | // and the password is the app’s client_secret | ||
| 228 | 8 | if (cached.clientSecret) { | |
| 229 | 1 | requestOptions.headers.Authorization = "Basic " + Buffer.from(cached.clientId + ":" + cached.clientSecret).toString("base64"); | |
| 230 | 1 | debug(`Using state.clientSecret to construct the authorization header: "${requestOptions.headers.Authorization}"`); | |
| 231 | } | ||
| 232 | else { | ||
| 233 | 7 | debug(`No clientSecret found in state. Adding client_id to the POST body`); | |
| 234 | 7 | requestOptions.data.client_id = cached.clientId; | |
| 235 | } | ||
| 236 | 8 | return requestOptions; | |
| 237 | } | ||
| 238 | 1 | exports.buildTokenRequest = buildTokenRequest; | |
| 239 | /** | ||
| 240 | * Creates and returns an HttpError that should explain why the token request | ||
| 241 | * have failed | ||
| 242 | */ | ||
| 243 | function handleTokenError(result) { | ||
| 244 | 7 | let msg = result.message; | |
| 245 | // Some auth servers will respond with a payload that contains "error" and | ||
| 246 | // optionally an "error_description" properties | ||
| 247 | 7 | const body = getPath(result, "response.data"); | |
| 248 | 7 | if (body) { | |
| 249 | 5 | if (body.error) { | |
| 250 | 2 | msg += "\n" + body.error; | |
| 251 | 2 | if (body.error_description) { | |
| 252 | 1 | msg += ": " + body.error_description; | |
| 253 | } | ||
| 254 | } | ||
| 255 | else { | ||
| 256 | 3 | msg += "\n" + body; | |
| 257 | } | ||
| 258 | } | ||
| 259 | 7 | debug(msg + " - " + result.stack); | |
| 260 | 7 | return new HttpError(msg, result.status); | |
| 261 | } | ||
| 262 | 1 | exports.handleTokenError = handleTokenError; | |
| 263 | /** | ||
| 264 | * After successful authorization we have received a code and state parameters. | ||
| 265 | * Use this function to exchange that code for an access token and complete the | ||
| 266 | * authorization flow. | ||
| 267 | */ | ||
| 268 | async function completeAuth(req, storage) { | ||
| 269 | 7 | debug("Completing the code flow"); | |
| 270 | 7 | const { state } = Url.parse(req.url, true).query; | |
| 271 | 6 | const cached = await storage.get(state); | |
| 272 | 6 | const requestOptions = await buildTokenRequest(req, storage); | |
| 273 | 6 | requestOptions.data = qs.stringify(requestOptions.data); | |
| 274 | // The EHR authorization server SHALL return a JSON structure that | ||
| 275 | // includes an access token or a message indicating that the | ||
| 276 | // authorization request has been denied. | ||
| 277 | 6 | return axios_1.default(requestOptions) | |
| 278 | .then(async ({ data }) => { | ||
| 279 | debug(`Received tokenResponse. Saving it to the state...`); | ||
| 280 | 4 | cached.tokenResponse = data; | |
| 281 | 4 | return storage.set(state, cached); | |
| 282 | }) | ||
| 283 | .then(() => new client_1.default(cached)) | ||
| 284 | .catch(result => { | ||
| 285 | throw handleTokenError(result); | ||
| 286 | }); | ||
| 287 | } | ||
| 288 | 1 | exports.completeAuth = completeAuth; | |
| 289 | /** | ||
| 290 | * The default storage factory assumes that the request object has a session | ||
| 291 | * property which is an object that we are free to manipulate. This happens to | ||
| 292 | * be the case for Express using `express-session` and for Hapi using | ||
| 293 | * hapi-server-session`. | ||
| 294 | */ | ||
| 295 | function getSessionStorage(req) { | ||
| 296 | 30 | return { | |
| 297 | /** | ||
| 298 | * Sets (adds or updates) a value at the given key | ||
| 299 | * @param {String} key | ||
| 300 | * @param {any} value | ||
| 301 | * @returns {Promise<any>} A promise resolved with the stored value | ||
| 302 | */ | ||
| 303 | async set(key, value) { | ||
| 304 | req.session[key] = value; | ||
| 305 | 30 | return value; | |
| 306 | }, | ||
| 307 | /** | ||
| 308 | * Reads the value at the given key | ||
| 309 | * @param {String} key | ||
| 310 | * @returns {Promise<any>} A promise resolved with the stored value or undefined | ||
| 311 | */ | ||
| 312 | async get(key) { | ||
| 313 | return req.session[key]; | ||
| 314 | }, | ||
| 315 | /** | ||
| 316 | * Deletes the value at the given key (if one exists) | ||
| 317 | * @param {String} key | ||
| 318 | * @returns {Promise<Boolean>} A promise resolved with true if the value | ||
| 319 | * was removed or with false otherwise | ||
| 320 | */ | ||
| 321 | async unset(key) { | ||
| 322 | if (req.session.hasOwnProperty(key)) { | ||
| 323 | 12 | delete req.session[key]; | |
| 324 | 12 | return true; | |
| 325 | } | ||
| 326 | 2 | return false; | |
| 327 | } | ||
| 328 | }; | ||
| 329 | } | ||
| 330 | 1 | exports.getSessionStorage = getSessionStorage; | |
| 331 |