fhirclient
Version:
JavaScript client for Fast Healthcare Interoperability Resources
635 lines (634 loc) • 25.8 kB
JavaScript
Object.defineProperty(exports, "__esModule", {
value: true
});
exports.init = exports.buildTokenRequest = exports.ready = exports.onMessage = exports.isInPopUp = exports.isInFrame = exports.authorize = exports.getSecurityExtensions = exports.fetchWellKnownJson = exports.KEY = void 0;
/* global window */
const lib_1 = require("./lib");
const Client_1 = require("./Client");
const settings_1 = require("./settings");
Object.defineProperty(exports, "KEY", {
enumerable: true,
get: function () {
return settings_1.SMART_KEY;
}
});
const debug = lib_1.debug.extend("oauth2");
function isBrowser() {
return typeof window === "object";
}
/**
* Fetches the well-known json file from the given base URL.
* Note that the result is cached in memory (until the page is reloaded in the
* browser) because it might have to be re-used by the client
* @param baseUrl The base URL of the FHIR server
*/
async function fetchWellKnownJson(baseUrl = "/", requestOptions) {
const url = String(baseUrl).replace(/\/*$/, "/") + ".well-known/smart-configuration";
return (0, lib_1.getAndCache)(url, requestOptions).catch(ex => {
throw new Error(`Failed to fetch the well-known json "${url}". ${ex.message}`);
});
}
exports.fetchWellKnownJson = fetchWellKnownJson;
/**
* Fetch a "WellKnownJson" and extract the SMART endpoints from it
*/
async function getSecurityExtensionsFromWellKnownJson(baseUrl = "/", requestOptions) {
return fetchWellKnownJson(baseUrl, requestOptions).then(meta => {
if (!meta.authorization_endpoint || !meta.token_endpoint) {
throw new Error("Invalid wellKnownJson");
}
return {
registrationUri: meta.registration_endpoint || "",
authorizeUri: meta.authorization_endpoint,
tokenUri: meta.token_endpoint,
codeChallengeMethods: meta.code_challenge_methods_supported || []
};
});
}
/**
* Fetch a `CapabilityStatement` and extract the SMART endpoints from it
*/
async function getSecurityExtensionsFromConformanceStatement(baseUrl = "/", requestOptions) {
return (0, lib_1.fetchConformanceStatement)(baseUrl, requestOptions).then(meta => {
const nsUri = "http://fhir-registry.smarthealthit.org/StructureDefinition/oauth-uris";
const extensions = ((0, lib_1.getPath)(meta || {}, "rest.0.security.extension") || []).filter(e => e.url === nsUri).map(o => o.extension)[0];
const out = {
registrationUri: "",
authorizeUri: "",
tokenUri: "",
codeChallengeMethods: []
};
if (extensions) {
extensions.forEach(ext => {
if (ext.url === "register") {
out.registrationUri = ext.valueUri;
}
if (ext.url === "authorize") {
out.authorizeUri = ext.valueUri;
}
if (ext.url === "token") {
out.tokenUri = ext.valueUri;
}
});
}
return out;
});
}
/**
* Given a FHIR server, returns an object with it's Oauth security endpoints
* that we are interested in. This will try to find the info in both the
* `CapabilityStatement` and the `.well-known/smart-configuration`. Whatever
* Arrives first will be used and the other request will be aborted.
* @param [baseUrl = "/"] Fhir server base URL
*/
async function getSecurityExtensions(baseUrl = "/", wellKnownRequestOptions, conformanceRequestOptions) {
return getSecurityExtensionsFromWellKnownJson(baseUrl, wellKnownRequestOptions).catch(() => getSecurityExtensionsFromConformanceStatement(baseUrl, conformanceRequestOptions));
}
exports.getSecurityExtensions = getSecurityExtensions;
/**
* Starts the SMART Launch Sequence.
* > **IMPORTANT**:
* `authorize()` will end up redirecting you to the authorization server.
* This means that you should not add anything to the returned promise chain.
* Any code written directly after the authorize() call might not be executed
* due to that redirect!
* @param env
* @param [params]
*/
async function authorize(env, params = {}) {
const url = env.getUrl();
// Multiple config for EHR launches ---------------------------------------
if (Array.isArray(params)) {
const urlISS = url.searchParams.get("iss") || url.searchParams.get("fhirServiceUrl");
if (!urlISS) {
throw new Error('Passing in an "iss" url parameter is required if authorize ' + 'uses multiple configurations');
}
// pick the right config
const cfg = params.find(x => {
if (x.issMatch) {
if (typeof x.issMatch === "function") {
return !!x.issMatch(urlISS);
}
if (typeof x.issMatch === "string") {
return x.issMatch === urlISS;
}
if (x.issMatch instanceof RegExp) {
return x.issMatch.test(urlISS);
}
}
return false;
});
(0, lib_1.assert)(cfg, `No configuration found matching the current "iss" parameter "${urlISS}"`);
return await authorize(env, cfg);
}
// ------------------------------------------------------------------------
// Obtain input
const {
clientSecret,
fakeTokenResponse,
encounterId,
target,
width,
height,
pkceMode,
clientPublicKeySetUrl,
// Two deprecated values to use as fall-back values later
redirect_uri,
client_id
} = params;
let {
iss,
launch,
patientId,
fhirServiceUrl,
redirectUri,
noRedirect,
scope = "",
clientId,
completeInTarget,
clientPrivateJwk,
stateKey
} = params;
const storage = env.getStorage();
// For these, a url param takes precedence over inline option
iss = url.searchParams.get("iss") || iss;
fhirServiceUrl = url.searchParams.get("fhirServiceUrl") || fhirServiceUrl;
launch = url.searchParams.get("launch") || launch;
patientId = url.searchParams.get("patientId") || patientId;
clientId = url.searchParams.get("clientId") || clientId;
// If there's still no clientId or redirectUri, check deprecated params
if (!clientId) {
clientId = client_id;
}
if (!redirectUri) {
redirectUri = redirect_uri;
}
if (!redirectUri) {
redirectUri = env.relative(".");
} else if (!redirectUri.match(/^https?\:\/\//)) {
redirectUri = env.relative(redirectUri);
}
const serverUrl = String(iss || fhirServiceUrl || "");
// Validate input
if (!serverUrl) {
throw new Error("No server url found. It must be specified as `iss` or as " + "`fhirServiceUrl` parameter");
}
if (iss) {
debug("Making %s launch...", launch ? "EHR" : "standalone");
}
// append launch scope if needed
if (launch && !scope.match(/launch/)) {
scope += " launch";
}
if (isBrowser()) {
const inFrame = isInFrame();
const inPopUp = isInPopUp();
if ((inFrame || inPopUp) && completeInTarget !== true && completeInTarget !== false) {
// completeInTarget will default to true if authorize is called from
// within an iframe. This is to avoid issues when the entire app
// happens to be rendered in an iframe (including in some EHRs),
// even though that was not how the app developer's intention.
completeInTarget = inFrame;
// In this case we can't always make the best decision so ask devs
// to be explicit in their configuration.
console.warn('Your app is being authorized from within an iframe or popup ' + 'window. Please be explicit and provide a "completeInTarget" ' + 'option. Use "true" to complete the authorization in the ' + 'same window, or "false" to try to complete it in the parent ' + 'or the opener window. See http://docs.smarthealthit.org/client-js/api.html');
}
}
// If `authorize` is called, make sure we clear any previous state (in case
// this is a re-authorize)
const oldKey = await storage.get(settings_1.SMART_KEY);
await storage.unset(oldKey);
stateKey = stateKey !== null && stateKey !== void 0 ? stateKey : (0, lib_1.randomString)(16);
// Create initial state
const state = {
clientId,
scope,
redirectUri,
serverUrl,
clientSecret,
clientPrivateJwk,
tokenResponse: {},
key: stateKey,
completeInTarget,
clientPublicKeySetUrl
};
const fullSessionStorageSupport = isBrowser() ? (0, lib_1.getPath)(env, "options.fullSessionStorageSupport") : true;
if (fullSessionStorageSupport) {
await storage.set(settings_1.SMART_KEY, stateKey);
}
// fakeTokenResponse to override stuff (useful in development)
if (fakeTokenResponse) {
Object.assign(state.tokenResponse, fakeTokenResponse);
}
// Fixed patientId (useful in development)
if (patientId) {
Object.assign(state.tokenResponse, {
patient: patientId
});
}
// Fixed encounterId (useful in development)
if (encounterId) {
Object.assign(state.tokenResponse, {
encounter: encounterId
});
}
let redirectUrl = redirectUri + "?state=" + encodeURIComponent(stateKey);
// bypass oauth if fhirServiceUrl is used (but iss takes precedence)
if (fhirServiceUrl && !iss) {
debug("Making fake launch...");
await storage.set(stateKey, state);
if (noRedirect) {
return redirectUrl;
}
return await env.redirect(redirectUrl);
}
// Get oauth endpoints and add them to the state
const extensions = await getSecurityExtensions(serverUrl, params.wellKnownRequestOptions, params.conformanceRequestOptions);
Object.assign(state, extensions);
await storage.set(stateKey, state);
// If this happens to be an open server and there is no authorizeUri
if (!state.authorizeUri) {
if (noRedirect) {
return redirectUrl;
}
return await env.redirect(redirectUrl);
}
// build the redirect uri
const redirectParams = ["response_type=code", "client_id=" + encodeURIComponent(clientId || ""), "scope=" + encodeURIComponent(scope), "redirect_uri=" + encodeURIComponent(redirectUri), "aud=" + encodeURIComponent(serverUrl), "state=" + encodeURIComponent(stateKey)];
// also pass this in case of EHR launch
if (launch) {
redirectParams.push("launch=" + encodeURIComponent(launch));
}
if (shouldIncludeChallenge(extensions.codeChallengeMethods.includes('S256'), pkceMode)) {
let codes = await env.security.generatePKCEChallenge();
Object.assign(state, codes);
await storage.set(stateKey, state);
redirectParams.push("code_challenge=" + state.codeChallenge); // note that the challenge is ALREADY encoded properly
redirectParams.push("code_challenge_method=S256");
}
redirectUrl = state.authorizeUri + "?" + redirectParams.join("&");
if (noRedirect) {
return redirectUrl;
}
if (target && isBrowser()) {
let win;
win = await (0, lib_1.getTargetWindow)(target, width, height);
if (win !== self) {
try {
// Also remove any old state from the target window and then
// transfer the current state there
win.sessionStorage.removeItem(oldKey);
win.sessionStorage.setItem(stateKey, JSON.stringify(state));
} catch (ex) {
(0, lib_1.debug)(`Failed to modify window.sessionStorage. Perhaps it is from different origin?. Failing back to "_self". %s`, ex);
win = self;
}
}
if (win !== self) {
try {
win.location.href = redirectUrl;
self.addEventListener("message", onMessage);
} catch (ex) {
(0, lib_1.debug)(`Failed to modify window.location. Perhaps it is from different origin?. Failing back to "_self". %s`, ex);
self.location.href = redirectUrl;
}
} else {
self.location.href = redirectUrl;
}
return;
} else {
return await env.redirect(redirectUrl);
}
}
exports.authorize = authorize;
function shouldIncludeChallenge(S256supported, pkceMode) {
if (pkceMode === "disabled") {
return false;
}
if (pkceMode === "unsafeV1") {
return true;
}
if (pkceMode === "required") {
if (!S256supported) {
throw new Error("Required PKCE code challenge method (`S256`) was not found in the server's codeChallengeMethods declaration.");
}
return true;
}
return S256supported;
}
/**
* Checks if called within a frame. Only works in browsers!
* If the current window has a `parent` or `top` properties that refer to
* another window, returns true. If trying to access `top` or `parent` throws an
* error, returns true. Otherwise returns `false`.
*/
function isInFrame() {
try {
return self !== top && parent !== self;
} catch (e) {
return true;
}
}
exports.isInFrame = isInFrame;
/**
* Checks if called within another window (popup or tab). Only works in browsers!
* To consider itself called in a new window, this function verifies that:
* 1. `self === top` (not in frame)
* 2. `!!opener && opener !== self` The window has an opener
* 3. `!!window.name` The window has a `name` set
*/
function isInPopUp() {
try {
return self === top && !!opener && opener !== self && !!window.name;
} catch (e) {
return false;
}
}
exports.isInPopUp = isInPopUp;
/**
* Another window can send a "completeAuth" message to this one, making it to
* navigate to e.data.url
* @param e The message event
*/
function onMessage(e) {
if (e.data.type == "completeAuth" && e.origin === new URL(self.location.href).origin) {
window.removeEventListener("message", onMessage);
window.location.href = e.data.url;
}
}
exports.onMessage = onMessage;
/**
* The ready function should only be called on the page that represents
* the redirectUri. We typically land there after a redirect from the
* authorization server, but this code will also be executed upon subsequent
* navigation or page refresh.
*/
async function ready(env, options = {}) {
var _a, _b;
const url = env.getUrl();
const Storage = env.getStorage();
const params = url.searchParams;
let key = params.get("state") || options.stateKey;
const code = params.get("code") || options.code;
const authError = params.get("error");
const authErrorDescription = params.get("error_description");
if (!key) {
key = await Storage.get(settings_1.SMART_KEY);
}
// Start by checking the url for `error` and `error_description` parameters.
// This happens when the auth server rejects our authorization attempt. In
// this case it has no other way to tell us what the error was, other than
// appending these parameters to the redirect url.
// From client's point of view, this is not very reliable (because we can't
// know how we have landed on this page - was it a redirect or was it loaded
// manually). However, if `ready()` is being called, we can assume
// that the url comes from the auth server (otherwise the app won't work
// anyway).
if (authError || authErrorDescription) {
throw new Error([authError, authErrorDescription].filter(Boolean).join(": "));
}
debug("key: %s, code: %s", key, code);
// key might be coming from the page url so it might be empty or missing
(0, lib_1.assert)(key, "No 'state' parameter found. Please (re)launch the app.");
// Check if we have a previous state
let state = await Storage.get(key);
const fullSessionStorageSupport = isBrowser() ? (0, lib_1.getPath)(env, "options.fullSessionStorageSupport") : true;
// If we are in a popup window or an iframe and the authorization is
// complete, send the location back to our opener and exit.
if (isBrowser() && state && !state.completeInTarget) {
const inFrame = isInFrame();
const inPopUp = isInPopUp();
// we are about to return to the opener/parent where completeAuth will
// be called again. In rare cases the opener or parent might also be
// a frame or popup. Then inFrame or inPopUp will be true but we still
// have to stop going up the chain. To guard against that weird form of
// recursion we pass one additional parameter to the url which we later
// remove.
if ((inFrame || inPopUp) && !url.searchParams.get("complete")) {
url.searchParams.set("complete", "1");
const {
href,
origin
} = url;
if (inFrame) {
parent.postMessage({
type: "completeAuth",
url: href
}, origin);
}
if (inPopUp) {
opener.postMessage({
type: "completeAuth",
url: href
}, origin);
window.close();
}
return new Promise(() => {});
}
}
url.searchParams.delete("complete");
// Do we have to remove the `code` and `state` params from the URL?
const hasState = params.has("state") || options.stateKey ? true : false;
if (isBrowser() && (0, lib_1.getPath)(env, "options.replaceBrowserHistory") && (code || hasState)) {
// `code` is the flag that tell us to request an access token.
// We have to remove it, otherwise the page will authorize on
// every load!
if (code) {
params.delete("code");
debug("Removed code parameter from the url.");
}
// If we have `fullSessionStorageSupport` it means we no longer
// need the `state` key. It will be stored to a well know
// location - sessionStorage[SMART_KEY]. However, no
// fullSessionStorageSupport means that this "well know location"
// might be shared between windows and tabs. In this case we
// MUST keep the `state` url parameter.
if (hasState && fullSessionStorageSupport) {
params.delete("state");
debug("Removed state parameter from the url.");
}
// If the browser does not support the replaceState method for the
// History Web API, the "code" parameter cannot be removed. As a
// consequence, the page will (re)authorize on every load. The
// workaround is to reload the page to new location without those
// parameters. If that is not acceptable replaceBrowserHistory
// should be set to false.
if (window.history.replaceState) {
window.history.replaceState({}, "", url.href);
}
}
// If the state does not exist, it means the page has been loaded directly.
(0, lib_1.assert)(state, "No state found! Please (re)launch the app.");
// Assume the client has already completed a token exchange when
// there is no code (but we have a state) or access token is found in state
const authorized = !code || ((_a = state.tokenResponse) === null || _a === void 0 ? void 0 : _a.access_token);
// If we are authorized already, then this is just a reload.
// Otherwise, we have to complete the code flow
if (!authorized && state.tokenUri) {
(0, lib_1.assert)(code, "'code' url parameter is required");
debug("Preparing to exchange the code for access token...");
const requestOptions = await buildTokenRequest(env, {
code,
state,
clientPublicKeySetUrl: options.clientPublicKeySetUrl,
privateKey: options.privateKey || state.clientPrivateJwk
});
debug("Token request options: %O", requestOptions);
// The EHR authorization server SHALL return a JSON structure that
// includes an access token or a message indicating that the
// authorization request has been denied.
const tokenResponse = await (0, lib_1.request)(state.tokenUri, requestOptions);
debug("Token response: %O", tokenResponse);
(0, lib_1.assert)(tokenResponse.access_token, "Failed to obtain access token.");
// Now we need to determine when is this authorization going to expire
state.expiresAt = (0, lib_1.getAccessTokenExpiration)(tokenResponse, env);
// save the tokenResponse so that we don't have to re-authorize on
// every page reload
state = Object.assign(Object.assign({}, state), {
tokenResponse
});
await Storage.set(key, state);
debug("Authorization successful!");
} else {
debug(((_b = state.tokenResponse) === null || _b === void 0 ? void 0 : _b.access_token) ? "Already authorized" : "No authorization needed");
}
if (fullSessionStorageSupport) {
await Storage.set(settings_1.SMART_KEY, key);
}
const client = new Client_1.default(env, state);
debug("Created client instance: %O", client);
return client;
}
exports.ready = ready;
/**
* Builds the token request options. Does not make the request, just
* creates it's configuration and returns it in a Promise.
*/
async function buildTokenRequest(env, {
code,
state,
clientPublicKeySetUrl,
privateKey
}) {
const {
redirectUri,
clientSecret,
tokenUri,
clientId,
codeVerifier
} = state;
(0, lib_1.assert)(redirectUri, "Missing state.redirectUri");
(0, lib_1.assert)(tokenUri, "Missing state.tokenUri");
(0, lib_1.assert)(clientId, "Missing state.clientId");
const requestOptions = {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded"
},
body: `code=${code}&grant_type=authorization_code&redirect_uri=${encodeURIComponent(redirectUri)}`
};
// For public apps, authentication is not possible (and thus not required),
// since a client with no secret cannot prove its identity when it issues a
// call. (The end-to-end system can still be secure because the client comes
// from a known, https protected endpoint specified and enforced by the
// redirect uri.) For confidential apps, an Authorization header using HTTP
// Basic authentication is required, where the username is the app’s
// client_id and the password is the app’s client_secret (see example).
if (clientSecret) {
requestOptions.headers.authorization = "Basic " + env.btoa(clientId + ":" + clientSecret);
debug("Using state.clientSecret to construct the authorization header: %s", requestOptions.headers.authorization);
}
// Asymmetric auth
else if (privateKey) {
const pk = "key" in privateKey ? privateKey.key : await env.security.importJWK(privateKey);
const jwtHeaders = {
typ: "JWT",
kid: privateKey.kid,
jku: clientPublicKeySetUrl || state.clientPublicKeySetUrl
};
const jwtClaims = {
iss: clientId,
sub: clientId,
aud: tokenUri,
jti: env.base64urlencode(env.security.randomBytes(32)),
exp: (0, lib_1.getTimeInFuture)(120) // two minutes in the future
};
const clientAssertion = await env.security.signCompactJws(privateKey.alg, pk, jwtHeaders, jwtClaims);
requestOptions.body += `&client_assertion_type=${encodeURIComponent("urn:ietf:params:oauth:client-assertion-type:jwt-bearer")}`;
requestOptions.body += `&client_assertion=${encodeURIComponent(clientAssertion)}`;
debug("Using state.clientPrivateJwk to add a client_assertion to the POST body");
}
// Public client
else {
debug("Public client detected; adding state.clientId to the POST body");
requestOptions.body += `&client_id=${encodeURIComponent(clientId)}`;
}
if (codeVerifier) {
debug("Found state.codeVerifier, adding to the POST body");
// Note that the codeVerifier is ALREADY encoded properly
requestOptions.body += "&code_verifier=" + codeVerifier;
}
return requestOptions;
}
exports.buildTokenRequest = buildTokenRequest;
/**
* This function can be used when you want to handle everything in one page
* (no launch endpoint needed). You can think of it as if it does:
* ```js
* authorize(options).then(ready)
* ```
*
* **Be careful with init()!** There are some details you need to be aware of:
*
* 1. It will only work if your launch_uri is the same as your redirect_uri.
* While this should be valid, we can’t promise that every EHR will allow you
* to register client with such settings.
* 2. Internally, `init()` will be called twice. First it will redirect to the
* EHR, then the EHR will redirect back to the page where init() will be
* called again to complete the authorization. This is generally fine,
* because the returned promise will only be resolved once, after the second
* execution, but please also consider the following:
* - You should wrap all your app’s code in a function that is only executed
* after `init()` resolves!
* - Since the page will be loaded twice, you must be careful if your code
* has global side effects that can persist between page reloads
* (for example writing to localStorage).
* 3. For standalone launch, only use init in combination with offline_access
* scope. Once the access_token expires, if you don’t have a refresh_token
* there is no way to re-authorize properly. We detect that and delete the
* expired access token, but it still means that the user will have to
* refresh the page twice to re-authorize.
* @param env The adapter
* @param authorizeOptions The authorize options
*/
async function init(env, authorizeOptions, readyOptions) {
const url = env.getUrl();
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
// if `code` and `state` params are present we need to complete the auth flow
if (code && state) {
return ready(env, readyOptions);
}
// Check for existing client state. If state is found, it means a client
// instance have already been created in this session and we should try to
// "revive" it.
const storage = env.getStorage();
const key = state || (await storage.get(settings_1.SMART_KEY));
const cached = await storage.get(key);
if (cached) {
return new Client_1.default(env, cached);
}
// Otherwise try to launch
return authorize(env, authorizeOptions).then(() => {
// `init` promises a Client but that cannot happen in this case. The
// browser will be redirected (unload the page and be redirected back
// to it later and the same init function will be called again). On
// success, authorize will resolve with the redirect url but we don't
// want to return that from this promise chain because it is not a
// Client instance. At the same time, if authorize fails, we do want to
// pass the error to those waiting for a client instance.
return new Promise(() => {});
});
}
exports.init = init;
;